From 68552673e8db83b199745eb89c3841bee7117813 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 10 Feb 2020 19:16:30 +0000 Subject: [PATCH 001/349] Jules/integ 851 profile (#1) --- CHANGELOG.md | 14 + README.md | 158 +-------- c42sec/.DS_Store | Bin 0 -> 6148 bytes {c42seceventcli => c42sec}/__init__.py | 0 .../common => c42sec}/cursor_store.py | 37 +- c42sec/main.py | 33 ++ .../aed => c42sec/profile}/__init__.py | 0 c42sec/profile/_config.py | 111 ++++++ c42sec/profile/_password.py | 37 ++ c42sec/profile/profile.py | 189 ++++++++++ .../common => c42sec/send_to}/__init__.py | 0 c42sec/send_to/send_to.py | 7 + c42sec/util.py | 34 ++ .../fed => c42sec/write_to}/__init__.py | 0 c42sec/write_to/write_to.py | 7 + c42seceventcli/aed/args.py | 154 --------- c42seceventcli/aed/cursor_store.py | 36 -- c42seceventcli/aed/main.py | 171 ---------- c42seceventcli/common/cli_args.py | 173 ---------- c42seceventcli/common/util.py | 144 -------- setup.py | 6 +- tests/aed/test_args.py | 109 ------ tests/aed/test_cursor_store.py | 81 ----- tests/aed/test_main.py | 315 ----------------- tests/common/__init__.py | 0 tests/common/test_common.py | 322 ------------------ tests/conftest.py | 8 - tests/{aed => profile}/__init__.py | 0 tests/profile/conftest.py | 33 ++ tests/profile/test_config.py | 124 +++++++ tests/profile/test_password.py | 62 ++++ tests/profile/test_profile.py | 221 ++++++++++++ tests/{common => }/test_cursor_store.py | 13 +- 33 files changed, 929 insertions(+), 1670 deletions(-) create mode 100644 c42sec/.DS_Store rename {c42seceventcli => c42sec}/__init__.py (100%) rename {c42seceventcli/common => c42sec}/cursor_store.py (57%) create mode 100644 c42sec/main.py rename {c42seceventcli/aed => c42sec/profile}/__init__.py (100%) create mode 100644 c42sec/profile/_config.py create mode 100644 c42sec/profile/_password.py create mode 100644 c42sec/profile/profile.py rename {c42seceventcli/common => c42sec/send_to}/__init__.py (100%) create mode 100644 c42sec/send_to/send_to.py create mode 100644 c42sec/util.py rename {c42seceventcli/fed => c42sec/write_to}/__init__.py (100%) create mode 100644 c42sec/write_to/write_to.py delete mode 100644 c42seceventcli/aed/args.py delete mode 100644 c42seceventcli/aed/cursor_store.py delete mode 100644 c42seceventcli/aed/main.py delete mode 100644 c42seceventcli/common/cli_args.py delete mode 100644 c42seceventcli/common/util.py delete mode 100644 tests/aed/test_args.py delete mode 100644 tests/aed/test_cursor_store.py delete mode 100644 tests/aed/test_main.py delete mode 100644 tests/common/__init__.py delete mode 100644 tests/common/test_common.py delete mode 100644 tests/conftest.py rename tests/{aed => profile}/__init__.py (100%) create mode 100644 tests/profile/conftest.py create mode 100644 tests/profile/test_config.py create mode 100644 tests/profile/test_password.py create mode 100644 tests/profile/test_profile.py rename tests/{common => }/test_cursor_store.py (76%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2761367ce..1395ae308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Removed +- Removed config file settings and `-c` CLI arg. Use `c42sec profile set`. +- Removed `--clear-password` CLI argument. Use `c42sec profile set -p`. You will be prompted. + +### Added +- Added ability to view your profile: `c42sec profile show`. + +### Changed +- Renamed `c42aed` to `c42sec`. +- Moved CLI arguments `-s`, `-u`, and `--ignore-ssl-errors` to `c42sec profile set` command. + + ## 0.1.1 - 2019-10-29 ### Fixed diff --git a/README.md b/README.md index 88d628a19..4971c4dd3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# c42seceventcli - AED +# c42sec The c42seceventcli AED module contains a CLI tool for extracting AED events as well as an optional state manager for recording timestamps. The state manager records timestamps so that on future runs, @@ -10,15 +10,7 @@ you only extract events you did not previously extract. - Code42 Server 6.8.x+ ## Installation -Until we are able to put `py42` and `c42secevents` on PyPI, you will need to first install them manually. - -`py42` is available for download [here](https://confluence.corp.code42.com/pages/viewpage.action?pageId=61767969#py42%E2%80%93Code42PythonSDK-Downloads). -For py42 installation instructions, see its [README](https://stash.corp.code42.com/projects/SH/repos/lib_c42_python_sdk/browse/README.md). - -`c42secevents` is available [here](https://confluence.corp.code42.com/display/LS/Security+Event+Extractor+-+Python). -For `c42secevents` installation instructions, see its [README](https://stash.corp.code42.com/projects/INT/repos/security-event-extractor/browse/README.md). - -Once you've done that, install `c42seceventcli` using: +Install `c42sec` using: ```bash $ python setup.py install @@ -26,154 +18,18 @@ $ python setup.py install ## Usage -A simple usage requires you to pass in your Code42 authority URL and username as arguments: - -```bash -c42aed -s https://example.authority.com -u security.admin@example.com -``` - -Another option is to put your Code42 authority URL and username (and other arguments) in a config file. -Use `default.config.cfg` as an example to make your own config file; it has all the supported arguments. -The arguments in `default.config.cfg` mirror the CLI arguments. - -```buildoutcfg -[Code42] -c42_authority_url=https://example.authority.com -c42_username=user@code42.com -``` - -Then, run the script as follows: - -```bash -c42aed -c path/to/config -``` - -To use the state management service, simply provide the `-r` to the command line. -`-r` is particularly useful if you wish to run this script on a recurring job: - -```bash -c42aed -s https://example.authority.com -u security.admin@example.com -r -``` - -If you are using a config file with `-c`, set `record_cursor` to True: - -```buildoutcfg -[Code42] -c42_authority_url=https://example.authority.com -c42_username=user@code42.com -record_cursor=True -``` -By excluding `-r`, future runs will not know about previous events you got, and -you will get all the events in the given time range (or default time range of 60 days back). - -To clear the cursor: - -```bash -c42aed -s https://example.authority.com -u security.admin@example.com -r --clear-cursor -``` -There are two possible output formats. - -* CEF -* JSON - -JSON is the default. To use CEF, use `-o CEF`: +First, set your profile ```bash -c42aed -s https://example.authority.com -u security.admin@example.com -o CEF -``` - -Or if you are using a config file with `-c`: - -```buildoutcfg -[Code42] -c42_authority_url=https://example.authority.com -c42_username=user@code42.com -output_format=CEF +c42sec profile set -s https://example.authority.com -u security.admin@example.com -p ``` -There are three possible destination types to use: - -* stdout -* file - writing to a file -* server - transmitting to a server, such as syslog +`-p` will prompt for your password securely. If your username does not have a password stored, you will be prompted anyway. -The program defaults to `stdout`. To use a file, use `--dest-type` and `--dest` this way: +To ignore SSL errors, do: ```bash -c42aed -s https://example.authority.com -u security.admin@example.com --dest-type file --dest name-of-file.txt -``` - -To use a server destination (like syslog): - -```bash -c42aed -s https://example.authority.com -u security.admin@example.com --dest-type server --dest https://syslog.example.com -``` - -Both `destination_type` and `destination` are possible fields in the config file as well. - -You can also use CLI arguments with config file arguments, but the program will favor the CLI arguments. - -If this is your first time running, you will be prompted for your Code42 password. - -If you get a keychain error when running this script, you may have to add a code signature: - -```bash -codesign -f -s - $(which python) -``` - -All errors are sent to an error log file named `c42seceventcli_aed_errors.log` -located in your user directory under `.c42seceventcli/log`. - -Full usage: - -``` -usage: c42aed [-h] [--clear-cursor] [--reset-password] [-c CONFIG_FILE] - [-s C42_AUTHORITY_URL] [-u C42_USERNAME] [-b BEGIN_DATE] [-i] - [-o {CEF,JSON}] - [-t [{SharedViaLink,SharedToDomain,ApplicationRead,CloudStorage,RemovableMedia,IsPublic} [{SharedViaLink,SharedToDomain,ApplicationRead,CloudStorage,RemovableMedia,IsPublic} ...]]] - [-d--debug] [--dest-type {stdout,file,server}] - [--dest DESTINATION] [--dest-port DESTINATION_PORT] - [--dest-protocol {TCP,UDP}] [-e END_DATE | -r] - -optional arguments: - -h, --help show this help message and exit - --clear-cursor Resets the stored cursor. - --reset-password Clears stored password and prompts user for password. - -c CONFIG_FILE, --config-file CONFIG_FILE - The path to the config file to use for the rest of the - arguments. - -s C42_AUTHORITY_URL, --server C42_AUTHORITY_URL - The full scheme, url and port of the Code42 server. - -u C42_USERNAME, --username C42_USERNAME - The username of the Code42 API user. - -b BEGIN_DATE, --begin BEGIN_DATE - The beginning of the date range in which to look for - events, in YYYY-MM-DD UTC format OR a number (number - of minutes ago). - -i, --ignore-ssl-errors - Do not validate the SSL certificates of Code42 - servers. - -o {CEF,JSON}, --output-format {CEF,JSON} - The format used for outputting events. - -t [{SharedViaLink,SharedToDomain,ApplicationRead,CloudStorage,RemovableMedia,IsPublic} [{SharedViaLink,SharedToDomain,ApplicationRead,CloudStorage,RemovableMedia,IsPublic} ...]], --types [{SharedViaLink,SharedToDomain,ApplicationRead,CloudStorage,RemovableMedia,IsPublic} [{SharedViaLink,SharedToDomain,ApplicationRead,CloudStorage,RemovableMedia,IsPublic} ...]] - To limit extracted events to those with given exposure - types. - -d--debug Turn on debug logging. - --dest-type {stdout,file,server} - The type of destination to send output to. - --dest DESTINATION Either a name of a local file or syslog host address. - Ignored if destination type is 'stdout'. - --dest-port DESTINATION_PORT - Port used when sending logs to server. Ignored if - destination type is not 'server'. - --dest-protocol {TCP,UDP} - Protocol used to send logs to server. Ignored if - destination type is not 'server'. - -e END_DATE, --end END_DATE - The end of the date range in which to look for events, - in YYYY-MM-DD UTC format OR a number (number of - minutes ago). - -r, --record-cursor Only get events that were not previously retrieved. +c42sec profile set --ignore-ssl-errors true ``` # Known Issues diff --git a/c42sec/.DS_Store b/c42sec/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..271b16bf50444d4f5f29c62aa5a97fb05132aff2 GIT binary patch literal 6148 zcmeHKJ5Iw;5S)b+kjhw&^Wh#?16a}>@#$f1e&2m&H4zidoOLdN>r2rQa}oPE8yRUMtAImV`6+d7-9q< zE|?DEI%WxC^8~RMj)~0BEUCn#T8$W%bmm*t^};bR>986;te$K&p;$bf=eH<_^+ZJ} zAO)rh+~#)e{r`df!~8!bX(t7wz`s(!7Teu+!&j=_I(s?qwT-@~d(9W!jq9K=L^~!% iJLbmQ@m&;UUGp{X_rftT=*$P5sGkAXMJ5IQT7e7eyA}%o literal 0 HcmV?d00001 diff --git a/c42seceventcli/__init__.py b/c42sec/__init__.py similarity index 100% rename from c42seceventcli/__init__.py rename to c42sec/__init__.py diff --git a/c42seceventcli/common/cursor_store.py b/c42sec/cursor_store.py similarity index 57% rename from c42seceventcli/common/cursor_store.py rename to c42sec/cursor_store.py index 1962d245d..06576d4fb 100644 --- a/c42seceventcli/common/cursor_store.py +++ b/c42sec/cursor_store.py @@ -1,5 +1,7 @@ import sqlite3 -from c42seceventcli.common.util import get_user_project_path +from c42sec.util import get_user_project_path + +_INSERTION_TIMESTAMP_FIELD_NAME = u"insertionTimestamp" class SecurityEventCursorStore(object): @@ -48,3 +50,36 @@ def _is_empty(self): query_result = cursor.fetchone() if query_result: return int(query_result[0]) <= 0 + + +class AEDCursorStore(SecurityEventCursorStore): + _PRIMARY_KEY = 1 + + def __init__(self, db_file_path=None): + super(AEDCursorStore, self).__init__("aed_checkpoint", db_file_path) + if self._is_empty(): + self._init_table() + + def get_stored_insertion_timestamp(self): + rows = self._get(_INSERTION_TIMESTAMP_FIELD_NAME, self._PRIMARY_KEY) + if rows and rows[0]: + return rows[0][0] + + def replace_stored_insertion_timestamp(self, new_insertion_timestamp): + self._set( + column_name=_INSERTION_TIMESTAMP_FIELD_NAME, + new_value=new_insertion_timestamp, + primary_key=self._PRIMARY_KEY, + ) + + def reset(self): + self._drop_table() + self._init_table() + + def _init_table(self): + columns = "{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, _INSERTION_TIMESTAMP_FIELD_NAME) + create_table_query = "CREATE TABLE {0} ({1})".format(self._table_name, columns) + insert_query = "INSERT INTO {0} VALUES(?, null)".format(self._table_name) + with self._connection as conn: + conn.execute(create_table_query) + conn.execute(insert_query, (self._PRIMARY_KEY,)) diff --git a/c42sec/main.py b/c42sec/main.py new file mode 100644 index 000000000..a08c5451f --- /dev/null +++ b/c42sec/main.py @@ -0,0 +1,33 @@ +from argparse import ArgumentParser +from c42sec.profile import profile +from c42sec.send_to import send_to +from c42sec.write_to import write_to + + +def main(): + c42sec_arg_parser = ArgumentParser() + subcommand_parser = c42sec_arg_parser.add_subparsers() + _init_subcommands(subcommand_parser) + args = c42sec_arg_parser.parse_args() + _call(args, c42sec_arg_parser.print_help) + + +def _init_subcommands(subcommand_parser): + profile.init(subcommand_parser) + send_to.init(subcommand_parser) + write_to.init(subcommand_parser) + + +def _call(args, print_help): + """Call provided subcommand with args.""" + try: + args.func(args) + except AttributeError as err: + if str(err) == "'Namespace' object has no attribute 'func'": + print_help() + else: + print(err) + + +if __name__ == "__main__": + main() diff --git a/c42seceventcli/aed/__init__.py b/c42sec/profile/__init__.py similarity index 100% rename from c42seceventcli/aed/__init__.py rename to c42sec/profile/__init__.py diff --git a/c42sec/profile/_config.py b/c42sec/profile/_config.py new file mode 100644 index 000000000..a4370fc1d --- /dev/null +++ b/c42sec/profile/_config.py @@ -0,0 +1,111 @@ +import os +import c42sec.util as util +from configparser import ConfigParser + + +class ConfigurationKeys(object): + USER_SECTION = u"Code42" + AUTHORITY_KEY = u"c42_authority_url" + USERNAME_KEY = u"c42_username" + IGNORE_SSL_ERRORS_KEY = u"ignore-ssl-errors" + INTERNAL_SECTION = u"Internal" + HAS_SET_PROFILE_KEY = u"has_set_profile" + + +def get_config_profile(): + parser = ConfigParser() + if not profile_has_been_set(): + util.print_error("Profile is not set.") + print("") + print("To set, use: ") + util.print_bold("\tc42sec profile set -s -u ") + print("") + exit(1) + + return _get_config_profile_from_parser(parser) + + +def mark_as_set(): + parser = ConfigParser() + config_file_path = _get_config_file_path() + parser.read(config_file_path) + settings = parser[ConfigurationKeys.INTERNAL_SECTION] + settings[ConfigurationKeys.HAS_SET_PROFILE_KEY] = "True" + _save(parser, ConfigurationKeys.HAS_SET_PROFILE_KEY) + + +def profile_has_been_set(): + parser = ConfigParser() + config_file_path = _get_config_file_path() + parser.read(config_file_path) + settings = parser[ConfigurationKeys.INTERNAL_SECTION] + return settings.getboolean(ConfigurationKeys.HAS_SET_PROFILE_KEY) + + +def set_username(new_username): + parser = ConfigParser() + profile = _get_config_profile_from_parser(parser) + profile[ConfigurationKeys.USERNAME_KEY] = new_username + _save(parser, ConfigurationKeys.USERNAME_KEY) + + +def set_authority_url(new_url): + parser = ConfigParser() + profile = _get_config_profile_from_parser(parser) + profile[ConfigurationKeys.AUTHORITY_KEY] = new_url + _save(parser, ConfigurationKeys.AUTHORITY_KEY) + + +def set_ignore_ssl_errors(new_value): + parser = ConfigParser() + profile = _get_config_profile_from_parser(parser) + profile[ConfigurationKeys.IGNORE_SSL_ERRORS_KEY] = str(new_value) + _save(parser, ConfigurationKeys.IGNORE_SSL_ERRORS_KEY) + + +def _get_config_file_path(): + path = "{}config.cfg".format(util.get_user_project_path()) + if not os.path.exists(path): + _create_new_config_file(path) + + return path + + +def _get_config_profile_from_parser(parser): + config_file_path = _get_config_file_path() + parser.read(config_file_path) + config = parser[ConfigurationKeys.USER_SECTION] + config.ignore_ssl_errors = config.getboolean(ConfigurationKeys.IGNORE_SSL_ERRORS_KEY) + return config + + +def _create_new_config_file(path): + config_parser = ConfigParser() + config_parser = _create_user_section(config_parser) + config_parser = _create_internal_section(config_parser) + _save(config_parser, None, path) + + +def _create_user_section(parser): + keys = ConfigurationKeys + parser.add_section(keys.USER_SECTION) + parser[keys.USER_SECTION] = {} + parser[keys.USER_SECTION][keys.AUTHORITY_KEY] = "null" + parser[keys.USER_SECTION][keys.USERNAME_KEY] = "null" + parser[keys.USER_SECTION][keys.IGNORE_SSL_ERRORS_KEY] = "False" + return parser + + +def _create_internal_section(parser): + keys = ConfigurationKeys + parser.add_section(keys.INTERNAL_SECTION) + parser[keys.INTERNAL_SECTION] = {} + parser[keys.INTERNAL_SECTION][keys.HAS_SET_PROFILE_KEY] = "False" + return parser + + +def _save(parser, key=None, path=None): + path = _get_config_file_path() if path is None else path + util.open_file(path, "w+", lambda f: parser.write(f)) + if key is not None: + print("'{}' has been successfully updated".format(key)) diff --git a/c42sec/profile/_password.py b/c42sec/profile/_password.py new file mode 100644 index 000000000..db22899a2 --- /dev/null +++ b/c42sec/profile/_password.py @@ -0,0 +1,37 @@ +import keyring +from getpass import getpass +import c42sec.profile._config as config +from c42sec.profile._config import ConfigurationKeys + + +_ROOT_SERVICE_NAME = u"c42sec" + + +def get_password(prompt_if_not_exists=True): + profile = config.get_config_profile() + service_name = _get_service_name(profile) + username = _get_username(profile) + password = keyring.get_password(service_name, username) + if password is None and prompt_if_not_exists: + return set_password() + + return password + + +def set_password(): + password = getpass() + profile = config.get_config_profile() + service_name = _get_service_name(profile) + username = _get_username(profile) + keyring.set_password(service_name, username, password) + print("'Code42 Password' updated.") + return password + + +def _get_service_name(profile): + authority_url = profile[ConfigurationKeys.AUTHORITY_KEY] + return "{}::{}".format(_ROOT_SERVICE_NAME, authority_url) + + +def _get_username(profile): + return profile[ConfigurationKeys.USERNAME_KEY] diff --git a/c42sec/profile/profile.py b/c42sec/profile/profile.py new file mode 100644 index 000000000..c4f6ee20f --- /dev/null +++ b/c42sec/profile/profile.py @@ -0,0 +1,189 @@ +import c42sec.profile._config as config +import c42sec.profile._password as password +from c42sec.profile._config import ConfigurationKeys +from c42sec.util import print_error + + +class C42SecProfile(object): + authority_url = "" + username = "" + ignore_ssl_errors = False + get_password = password.get_password + + +def init(subcommand_parser): + """Sets up the `profile` command with `show` and `set` subcommands. + `show` will print the current profile while `set` will modify profile properties. + Use `-h` after any subcommand for usage. + Args: + subcommand_parser: The subparsers group created by the parent parser + """ + parser_profile = subcommand_parser.add_parser("profile") + parser_profile.set_defaults(func=show_profile) + profile_subparsers = parser_profile.add_subparsers() + + parser_show = profile_subparsers.add_parser("show") + parser_set = profile_subparsers.add_parser("set") + + parser_show.set_defaults(func=show_profile) + parser_set.set_defaults(func=set_profile) + _add_set_command_args(parser_set) + + +def get_profile(): + # type: () -> C42SecProfile + """Returns the current profile object""" + profile_values = config.get_config_profile() + profile = C42SecProfile() + profile.authority_url = profile_values.get(ConfigurationKeys.AUTHORITY_KEY) + profile.username = profile_values.get(ConfigurationKeys.USERNAME_KEY) + profile.ignore_ssl_errors = profile_values.get(ConfigurationKeys.IGNORE_SSL_ERRORS_KEY) + return profile + + +def show_profile(*args): + """Prints the current profile to stdout.""" + profile = config.get_config_profile() + print("\nProfile:") + for key in profile: + print("\t* {} = {}".format(key, profile[key])) + + # Don't prompt here because it may be confusing from a UX perspective + if password.get_password(prompt_if_not_exists=False) is not None: + print("\t* A password is set.") + + print("") + + +def set_profile(args): + """Sets the current profile using command line arguments.""" + if not _verify_args_for_initial_profile_set(args): + exit(1) + elif not config.profile_has_been_set(): + config.mark_as_set() + + _try_set_authority_url(args) + _try_set_username(args) + _try_set_ignore_ssl_errors(args) + _try_set_password(args) + + if args.show: + show_profile() + + +def _add_set_command_args(parser): + _add_authority_arg(parser) + _add_username_arg(parser) + _add_password_arg(parser) + _add_disable_ssl_errors_arg(parser) + _add_enable_ssl_errors_arg(parser) + _add_show_arg(parser) + + +def _add_authority_arg(parser): + parser.add_argument( + "-s", + "--server", + action="store", + dest=ConfigurationKeys.AUTHORITY_KEY, + help="The full scheme, url and port of the Code42 server.", + ) + + +def _add_username_arg(parser): + parser.add_argument( + "-u", + "--username", + action="store", + dest=ConfigurationKeys.USERNAME_KEY, + help="The username of the Code42 API user.", + ) + + +def _add_password_arg(parser): + parser.add_argument( + "-p", + "--password", + action="store_true", + dest="do_set_c42_password", + help="The password for the Code42 API user. " "Passwords are not stored in plain text.", + ) + + +def _add_disable_ssl_errors_arg(parser): + parser.add_argument( + "--disable-ssl-errors", + action="store_true", + default=None, + dest="disable_ssl_errors", + help="Do not validate the SSL certificates of Code42 servers.", + ) + + +def _add_enable_ssl_errors_arg(parser): + parser.add_argument( + "--enable-ssl-errors", + action="store_true", + default=None, + dest="enable_ssl_errors", + help="Do not validate the SSL certificates of Code42 servers.", + ) + + +def _add_show_arg(parser): + parser.add_argument( + "--show", + action="store_true", + dest="show", + help="Whether to show the profile after setting it.", + ) + + +def _set_has_args(args): + return args.c42_authority_url is not None or args.c42_username is not None + + +def _try_set_authority_url(args): + if args.c42_authority_url is not None: + config.set_authority_url(args.c42_authority_url) + + +def _try_set_username(args): + if args.c42_username is not None: + config.set_username(args.c42_username) + + +def _try_set_ignore_ssl_errors(args): + if args.disable_ssl_errors is not None and not args.enable_ssl_errors: + config.set_ignore_ssl_errors(True) + + if args.enable_ssl_errors is not None: + config.set_ignore_ssl_errors(False) + + +def _try_set_password(args): + # Must happen after setting username + if args.do_set_c42_password: + password.set_password() + + # This will prompt use for password if it does not exist for the current user name. + password.get_password() + + +def _verify_args_for_initial_profile_set(args): + if not config.profile_has_been_set() and ( + args.c42_username is None or args.c42_authority_url is None + ): + if args.c42_username is None: + print_error("Missing username argument.") + + if args.c42_authority_url is None: + print_error("Missing Code42 Authority URL argument.") + + return False + + return True + + +if __name__ == "__main__": + show_profile() diff --git a/c42seceventcli/common/__init__.py b/c42sec/send_to/__init__.py similarity index 100% rename from c42seceventcli/common/__init__.py rename to c42sec/send_to/__init__.py diff --git a/c42sec/send_to/send_to.py b/c42sec/send_to/send_to.py new file mode 100644 index 000000000..d143d842b --- /dev/null +++ b/c42sec/send_to/send_to.py @@ -0,0 +1,7 @@ +def init(subcommand_parser): + send_to_parser = subcommand_parser.add_parser("send-to") + send_to_parser.set_defaults(func=send_to) + + +def send_to(args): + print("Send to called") diff --git a/c42sec/util.py b/c42sec/util.py new file mode 100644 index 000000000..868732496 --- /dev/null +++ b/c42sec/util.py @@ -0,0 +1,34 @@ +import sys +from os import path, makedirs + + +def get_input(prompt): + if sys.version_info >= (3, 0): + return input(prompt) + else: + return raw_input(prompt) + + +def get_user_project_path(subdir=""): + """The path on your user dir to /.c42sec/[subdir]""" + package_name = __name__.split(".")[0] + home = path.expanduser("~") + user_project_path = path.join(home, ".{0}".format(package_name), subdir) + + if not path.exists(user_project_path): + makedirs(user_project_path) + + return user_project_path + + +def open_file(file_path, mode, action): + with open(file_path, mode) as f: + action(f) + + +def print_error(error_text): + print("\033[91mERROR: {}\033[0m".format(error_text)) + + +def print_bold(bold_text): + print("\033[1m{}\033[0m".format(bold_text)) diff --git a/c42seceventcli/fed/__init__.py b/c42sec/write_to/__init__.py similarity index 100% rename from c42seceventcli/fed/__init__.py rename to c42sec/write_to/__init__.py diff --git a/c42sec/write_to/write_to.py b/c42sec/write_to/write_to.py new file mode 100644 index 000000000..367e50a14 --- /dev/null +++ b/c42sec/write_to/write_to.py @@ -0,0 +1,7 @@ +def init(subcommand_parser): + write_to_parser = subcommand_parser.add_parser("write-to") + write_to_parser.set_defaults(func=write_to) + + +def write_to(args): + print("Send to called") diff --git a/c42seceventcli/aed/args.py b/c42seceventcli/aed/args.py deleted file mode 100644 index 4f6b7a0ab..000000000 --- a/c42seceventcli/aed/args.py +++ /dev/null @@ -1,154 +0,0 @@ -from datetime import datetime, timedelta -from argparse import ArgumentParser - -from c42seceventcli.common.cli_args import ( - add_clear_cursor_arg, - add_reset_password_arg, - add_config_file_path_arg, - add_authority_host_address_arg, - add_username_arg, - add_begin_date_arg, - add_end_date_arg, - add_ignore_ssl_errors_arg, - add_output_format_arg, - add_record_cursor_arg, - add_exposure_types_arg, - add_debug_arg, - add_destination_type_arg, - add_destination_arg, - add_destination_port_arg, - add_destination_protocol_arg, -) -import c42seceventcli.common.util as common - - -def get_args(): - parser = _get_arg_parser() - cli_args = vars(parser.parse_args()) - args = _union_cli_args_with_config_file_args(cli_args) - args.cli_parser = parser - args.initialize_args() - args.verify_authority_arg() - args.verify_username_arg() - args.verify_destination_args() - return args - - -def _get_arg_parser(): - parser = ArgumentParser() - - add_clear_cursor_arg(parser) - add_reset_password_arg(parser) - add_config_file_path_arg(parser) - add_authority_host_address_arg(parser) - add_username_arg(parser) - add_begin_date_arg(parser) - add_ignore_ssl_errors_arg(parser) - add_output_format_arg(parser) - add_exposure_types_arg(parser) - add_debug_arg(parser) - add_destination_type_arg(parser) - add_destination_arg(parser) - add_destination_port_arg(parser) - add_destination_protocol_arg(parser) - - # Makes sure that you can't give both an end_timestamp and record_cursor - mutually_exclusive_timestamp_group = parser.add_mutually_exclusive_group() - add_end_date_arg(mutually_exclusive_timestamp_group) - add_record_cursor_arg(mutually_exclusive_timestamp_group) - - return parser - - -def _union_cli_args_with_config_file_args(cli_args): - config_args = _get_config_args(cli_args.get("config_file")) - args = AEDArgs() - keys = cli_args.keys() - for key in keys: - args.try_set(key, cli_args.get(key), config_args.get(key)) - - return args - - -def _get_config_args(config_file_path): - try: - return common.get_config_args(config_file_path) - except IOError: - print("Path to config file {0} not found".format(config_file_path)) - exit(1) - - -class AEDArgs(common.SecArgs): - cli_parser = None - c42_authority_url = None - c42_username = None - begin_date = None - end_date = None - ignore_ssl_errors = False - output_format = "JSON" - record_cursor = False - exposure_types = None - debug_mode = False - destination_type = "stdout" - destination = None - destination_port = 514 - destination_protocol = "TCP" - reset_password = False - clear_cursor = False - - def __init__(self): - self.begin_date = AEDArgs._get_default_begin_date() - self.end_date = AEDArgs._get_default_end_date() - - def initialize_args(self): - self.destination_type = self.destination_type.lower() - try: - self.destination_port = int(self.destination_port) - except ValueError: - msg = "Destination port '{0}' not a base 10 integer.".format(self.destination_port) - self._raise_value_error(msg) - - @staticmethod - def _get_default_begin_date(): - default_begin_date = datetime.now() - timedelta(days=60) - return default_begin_date.strftime("%Y-%m-%d") - - @staticmethod - def _get_default_end_date(): - default_end_date = datetime.now() - return default_end_date.strftime("%Y-%m-%d") - - def verify_authority_arg(self): - if self.c42_authority_url is None: - self._raise_value_error("Code42 authority host address not provided.") - - def verify_username_arg(self): - if self.c42_username is None: - self._raise_value_error("Code42 username not provided.") - - def verify_destination_args(self): - self._verify_stdout_destination() - self._verify_server_destination() - - def _verify_stdout_destination(self): - if self.destination_type == "stdout" and self.destination is not None: - msg = ( - "Destination '{0}' not applicable for stdout. " - "Try removing '--dest' arg or change '--dest-type' to 'file' or 'server'." - ) - msg = msg.format(self.destination) - self._raise_value_error(msg) - - def _verify_file_destination(self): - if self.destination_type == "file" and self.destination is None: - msg = "Missing file name. Try: '--dest path/to/file'." - self._raise_value_error(msg) - - def _verify_server_destination(self): - if self.destination_type == "server" and self.destination is None: - msg = "Missing server URL. Try: '--dest https://syslog.example.com'." - self._raise_value_error(msg) - - def _raise_value_error(self, msg): - self.cli_parser.print_usage() - raise ValueError(msg) diff --git a/c42seceventcli/aed/cursor_store.py b/c42seceventcli/aed/cursor_store.py deleted file mode 100644 index 4d0659f53..000000000 --- a/c42seceventcli/aed/cursor_store.py +++ /dev/null @@ -1,36 +0,0 @@ -from c42seceventcli.common.cursor_store import SecurityEventCursorStore - -_INSERTION_TIMESTAMP_FIELD_NAME = u"insertionTimestamp" - - -class AEDCursorStore(SecurityEventCursorStore): - _PRIMARY_KEY = 1 - - def __init__(self, db_file_path=None): - super(AEDCursorStore, self).__init__("aed_checkpoint", db_file_path) - if self._is_empty(): - self._init_table() - - def get_stored_insertion_timestamp(self): - rows = self._get(_INSERTION_TIMESTAMP_FIELD_NAME, self._PRIMARY_KEY) - if rows and rows[0]: - return rows[0][0] - - def replace_stored_insertion_timestamp(self, new_insertion_timestamp): - self._set( - column_name=_INSERTION_TIMESTAMP_FIELD_NAME, - new_value=new_insertion_timestamp, - primary_key=self._PRIMARY_KEY, - ) - - def reset(self): - self._drop_table() - self._init_table() - - def _init_table(self): - columns = "{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, _INSERTION_TIMESTAMP_FIELD_NAME) - create_table_query = "CREATE TABLE {0} ({1})".format(self._table_name, columns) - insert_query = "INSERT INTO {0} VALUES(?, null)".format(self._table_name) - with self._connection as conn: - conn.execute(create_table_query) - conn.execute(insert_query, (self._PRIMARY_KEY,)) diff --git a/c42seceventcli/aed/main.py b/c42seceventcli/aed/main.py deleted file mode 100644 index 53dad1244..000000000 --- a/c42seceventcli/aed/main.py +++ /dev/null @@ -1,171 +0,0 @@ -import json -from socket import gaierror, herror, timeout -from urllib3 import disable_warnings -from urllib3.exceptions import InsecureRequestWarning -from datetime import datetime, timedelta - -from py42 import debug_level -from py42 import settings -from py42.sdk import SDK -from c42secevents.extractors import AEDEventExtractor -from c42secevents.common import FileEventHandlers, convert_datetime_to_timestamp -from c42secevents.logging.formatters import AEDDictToCEFFormatter, AEDDictToJSONFormatter - -import c42seceventcli.common.util as common -import c42seceventcli.aed.args as aed_args -from c42seceventcli.aed.cursor_store import AEDCursorStore - -_SERVICE_NAME = u"c42seceventcli_aed" - - -def main(): - args = _get_args() - if args.reset_password: - common.delete_stored_password(_SERVICE_NAME, args.c42_username) - - handlers = _create_handlers(args) - _set_up_cursor_store( - record_cursor=args.record_cursor, clear_cursor=args.clear_cursor, handlers=handlers - ) - sdk = _create_sdk_from_args(args, handlers) - - if bool(args.ignore_ssl_errors): - _ignore_ssl_errors() - - if bool(args.debug_mode): - settings.debug_level = debug_level.DEBUG - - _extract(args=args, sdk=sdk, handlers=handlers) - - -def _get_args(): - try: - return aed_args.get_args() - except ValueError as ex: - print(repr(ex)) - exit(1) - - -def _ignore_ssl_errors(): - settings.verify_ssl_certs = False - disable_warnings(InsecureRequestWarning) - - -def _create_handlers(args): - handlers = FileEventHandlers() - error_logger = common.get_error_logger(_SERVICE_NAME) - settings.global_exception_message_receiver = error_logger.error - handlers.handle_error = error_logger.error - output_format = args.output_format - logger_formatter = _get_log_formatter(output_format) - destination_args = _create_destination_args(args) - logger = _get_logger( - formatter=logger_formatter, service_name=_SERVICE_NAME, destination_args=destination_args - ) - handlers.handle_response = _get_response_handler(logger) - return handlers - - -def _create_destination_args(args): - destination_args = common.DestinationArgs() - destination_args.destination_type = args.destination_type - destination_args.destination = args.destination - destination_args.destination_port = args.destination_port - destination_args.destination_protocol = args.destination_protocol - return destination_args - - -def _get_logger(formatter, service_name, destination_args): - try: - return common.get_logger( - formatter=formatter, service_name=service_name, destination_args=destination_args - ) - except (herror, gaierror, timeout) as ex: - print(repr(ex)) - _print_server_args(destination_args) - exit(1) - except IOError as ex: - print(repr(ex)) - if ex.errno == 61: - _print_server_args(destination_args) - exit(1) - - print("File path: {0}.".format(destination_args.destination)) - exit(1) - - -def _print_server_args(server_args): - print( - "Hostname={0}, port={1}, protocol={2}.".format( - server_args.destination, server_args.destination_port, server_args.destination_protocol - ) - ) - - -def _set_up_cursor_store(record_cursor, clear_cursor, handlers): - if record_cursor or clear_cursor: - store = AEDCursorStore() - if clear_cursor: - store.reset() - - if record_cursor: - handlers.record_cursor_position = store.replace_stored_insertion_timestamp - handlers.get_cursor_position = store.get_stored_insertion_timestamp - return store - - -def _get_log_formatter(output_format): - if output_format == "JSON": - return AEDDictToJSONFormatter() - elif output_format == "CEF": - return AEDDictToCEFFormatter() - else: - print("Unsupported output format {0}".format(output_format)) - exit(1) - - -def _get_response_handler(logger): - def handle_response(response): - response_dict = json.loads(response.text) - file_events_key = u"fileEvents" - if file_events_key in response_dict: - events = response_dict[file_events_key] - for event in events: - logger.info(event) - - return handle_response - - -def _create_sdk_from_args(args, handlers): - password = common.get_stored_password(_SERVICE_NAME, args.c42_username) - try: - sdk = SDK.create_using_local_account( - host_address=args.c42_authority_url, username=args.c42_username, password=password - ) - return sdk - except Exception as ex: - handlers.handle_error(ex) - print("Incorrect username or password.") - exit(1) - - -def _extract(args, sdk, handlers): - min_timestamp = _parse_min_timestamp(args.begin_date) - max_timestamp = common.parse_timestamp(args.end_date) - extractor = AEDEventExtractor(sdk, handlers) - extractor.extract(min_timestamp, max_timestamp, args.exposure_types) - - -def _parse_min_timestamp(begin_date): - min_timestamp = common.parse_timestamp(begin_date) - boundary_date = datetime.utcnow() - timedelta(days=90) - boundary = convert_datetime_to_timestamp(boundary_date) - if min_timestamp < boundary: - print("Argument '--begin' must be within 90 days.") - exit(1) - - return min_timestamp - - -if __name__ == "__main__": - main() diff --git a/c42seceventcli/common/cli_args.py b/c42seceventcli/common/cli_args.py deleted file mode 100644 index d25032523..000000000 --- a/c42seceventcli/common/cli_args.py +++ /dev/null @@ -1,173 +0,0 @@ -from argparse import SUPPRESS - - -def add_config_file_path_arg(arg_group): - arg_group.add_argument( - "-c", - "--config-file", - dest="config_file", - action="store", - help="The path to the config file to use for the rest of the arguments.", - ) - - -def add_clear_cursor_arg(arg_group): - arg_group.add_argument( - "--clear-cursor", - dest="clear_cursor", - action="store_true", - help="Resets the stored cursor.", - default=False, - ) - - -def add_reset_password_arg(arg_group): - arg_group.add_argument( - "--reset-password", - dest="reset_password", - action="store_true", - help="Clears stored password and prompts user for password.", - default=False, - ) - - -def add_authority_host_address_arg(arg_group): - arg_group.add_argument( - "-s", - "--server", - dest="c42_authority_url", - action="store", - help="The full scheme, url and port of the Code42 server.", - ) - - -def add_username_arg(arg_group): - arg_group.add_argument( - "-u", - "--username", - action="store", - dest="c42_username", - help="The username of the Code42 API user.", - ) - - -def add_begin_date_arg(arg_group): - arg_group.add_argument( - "-b", - "--begin", - action="store", - dest="begin_date", - help="The beginning of the date range in which to look for events, " - "in YYYY-MM-DD UTC format OR a number (number of minutes ago).", - ) - - -def add_end_date_arg(arg_group): - arg_group.add_argument( - "-e", - "--end", - action="store", - dest="end_date", - help="The end of the date range in which to look for events, " - "in YYYY-MM-DD UTC format OR a number (number of minutes ago).", - ) - - -def add_ignore_ssl_errors_arg(arg_group): - arg_group.add_argument( - "-i", - "--ignore-ssl-errors", - action="store_true", - dest="ignore_ssl_errors", - help="Do not validate the SSL certificates of Code42 servers.", - ) - - -def add_output_format_arg(arg_group): - arg_group.add_argument( - "-o", - "--output-format", - dest="output_format", - action="store", - choices=["CEF", "JSON"], - help="The format used for outputting events.", - ) - - -def add_record_cursor_arg(arg_group): - arg_group.add_argument( - "-r", - "--record-cursor", - dest="record_cursor", - action="store_true", - help="Only get events that were not previously retrieved.", - ) - - -def add_exposure_types_arg(arg_group): - arg_group.add_argument( - "-t", - "--types", - nargs="*", - action="store", - dest="exposure_types", - choices=[ - u"SharedViaLink", - u"SharedToDomain", - u"ApplicationRead", - u"CloudStorage", - u"RemovableMedia", - u"IsPublic", - ], - help="To limit extracted events to those with given exposure types.", - ) - - -def add_debug_arg(arg_group): - arg_group.add_argument( - "-d" "--debug", action="store_true", dest="debug_mode", help="Turn on debug logging." - ) - - -def add_destination_type_arg(arg_group): - arg_group.add_argument( - "--dest-type", - action="store", - dest="destination_type", - choices=["stdout", "file", "server"], - help="The type of destination to send output to.", - ) - - -def add_destination_arg(arg_group): - arg_group.add_argument( - "--dest", - action="store", - dest="destination", - help="Either a name of a local file or syslog host address. Ignored if destination type is 'stdout'.", - ) - - -def add_destination_port_arg(arg_group): - arg_group.add_argument( - "--dest-port", - action="store", - dest="destination_port", - help="Port used when sending logs to server. Ignored if destination type is not 'server'.", - ) - - -def add_destination_protocol_arg(arg_group): - arg_group.add_argument( - "--dest-protocol", - action="store", - dest="destination_protocol", - choices=["TCP", "UDP"], - help="Protocol used to send logs to server. Ignored if destination type is not 'server'.", - ) - - -def add_help_arg(arg_group): - arg_group.add_argument( - "-h", "--help", action="help", default=SUPPRESS, help="Show this help message and exit." - ) diff --git a/c42seceventcli/common/util.py b/c42seceventcli/common/util.py deleted file mode 100644 index 7a9a665be..000000000 --- a/c42seceventcli/common/util.py +++ /dev/null @@ -1,144 +0,0 @@ -import sys -import keyring -import getpass -import logging -from os import path, makedirs -from keyring.errors import PasswordDeleteError -from datetime import datetime, timedelta -from configparser import ConfigParser -from logging.handlers import RotatingFileHandler - -from c42secevents.logging.handlers import NoPrioritySysLogHandler -from c42secevents.common import convert_datetime_to_timestamp - - -def get_user_project_path(subdir=None): - """The path on your user dir to /.c42seceventcli/[subdir]""" - package_name = __name__.split(".")[0] - home = path.expanduser("~") - user_project_path = path.join(home, ".{0}".format(package_name), subdir) - - if not path.exists(user_project_path): - makedirs(user_project_path) - - return user_project_path - - -def get_config_args(config_file_path): - args = {} - parser = ConfigParser() - if config_file_path: - if not parser.read(path.expanduser(config_file_path)): - raise IOError("Supplied an empty config file {0}".format(config_file_path)) - - if not parser.sections(): - return args - - items = parser.items("Code42") - for item in items: - args[item[0]] = item[1] - - return args - - -def parse_timestamp(input_string): - try: - time = datetime.strptime(input_string, "%Y-%m-%d") - except ValueError: - if input_string and input_string.isdigit(): - now = datetime.utcnow() - time = now - timedelta(minutes=int(input_string)) - else: - raise ValueError("input must be a positive integer or a date in YYYY-MM-DD format.") - - return convert_datetime_to_timestamp(time) - - -def get_error_logger(service_name): - log_path = get_user_project_path("log") - log_path = "{0}/{1}_errors.log".format(log_path, service_name) - logger = logging.getLogger("{0}_error_logger".format(service_name)) - formatter = logging.Formatter("%(asctime)s %(message)s") - handler = RotatingFileHandler(log_path, maxBytes=250000000) - handler.setFormatter(formatter) - logger.addHandler(handler) - return logger - - -class DestinationArgs(object): - destination_type = None - destination = None - destination_port = None - destination_protocol = None - - -def get_logger(formatter, service_name, destination_args): - """Args: - formatter: The formatter for logger. - service_name: The name of the script getting the logger. - Necessary for distinguishing multiple loggers. - destination_args: DTO holding the destination_type, destination, destination_port, and destination_protocol. - Returns: - A logger with the correct handler per destination_type. - For destination_type == stdout, it uses a StreamHandler. - For destination_type == file, it uses a FileHandler. - For destination_type == server, it uses a NoPrioritySyslogHandler. - """ - - logger = logging.getLogger("{0}_logger".format(service_name)) - handler = _get_log_handler(destination_args) - handler.setFormatter(formatter) - logger.addHandler(handler) - logger.setLevel(logging.INFO) - return logger - - -def _get_log_handler(destination_args): - if destination_args.destination_type == "stdout": - return logging.StreamHandler(sys.stdout) - elif destination_args.destination_type == "server": - return NoPrioritySysLogHandler( - hostname=destination_args.destination, - port=destination_args.destination_port, - protocol=destination_args.destination_protocol, - ) - elif destination_args.destination_type == "file": - return logging.FileHandler(filename=destination_args.destination) - - -def get_stored_password(service_name, username): - password = keyring.get_password(service_name, username) - if password is None: - try: - password = getpass.getpass(prompt="Code42 password: ") - save_password = _get_input("Save password to keychain? (y/n): ") - if save_password.lower()[0] == "y": - keyring.set_password(service_name, username, password) - - except KeyboardInterrupt: - print() - exit(1) - - return password - - -def _get_input(prompt): - if sys.version_info >= (3, 0): - return input(prompt) - else: - return raw_input(prompt) - - -def delete_stored_password(service_name, username): - try: - keyring.delete_password(service_name, username) - except PasswordDeleteError: - return - - -class SecArgs(object): - def try_set(self, arg_name, cli_arg=None, config_arg=None): - if cli_arg is not None: - setattr(self, arg_name, cli_arg) - elif config_arg is not None: - setattr(self, arg_name, config_arg) diff --git a/setup.py b/setup.py index 7bf0d18ff..416071bf5 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,15 @@ from setuptools import find_packages, setup setup( - name="c42seceventcli", + name="c42sec", version="0.1.1", description="CLI for retrieving Code42 Exfiltration Detection events", - packages=find_packages(include=["c42seceventcli", "c42seceventcli.*"]), + packages=find_packages(include=["c42sec", "c42sec.*"]), python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=["c42secevents", "urllib3", "keyring==18.0.1"], license="MIT", include_package_data=True, zip_safe=False, extras_require={"dev": ["pre-commit==1.18.3", "pytest==4.6.5", "pytest-mock==1.10.4"]}, - entry_points={"console_scripts": ["c42aed=c42seceventcli.aed.main:main"]}, + entry_points={"console_scripts": ["c42sec=c42sec.main:main"]}, ) diff --git a/tests/aed/test_args.py b/tests/aed/test_args.py deleted file mode 100644 index d5640c185..000000000 --- a/tests/aed/test_args.py +++ /dev/null @@ -1,109 +0,0 @@ -import pytest -from argparse import Namespace - -from c42seceventcli.aed.args import get_args - - -@pytest.fixture -def patches(mocker, mock_cli_arg_parser, mock_cli_args, mock_config_arg_parser, mock_config_args): - mock = mocker.MagicMock() - mock.cli_args = mock_cli_args - mock.config_args = mock_config_args - return mock - - -@pytest.fixture -def patches_with_mocked_args_verifications( - mocker, - mock_cli_arg_parser, - mock_cli_args, - mock_config_arg_parser, - mock_config_args, - mock_authority_verification, - mock_username_verification, - mock_destination_args_verification, -): - mock = mocker.MagicMock() - mock.cli_args = mock_cli_args - mock.config_args = mock_config_args - mock.verify_authority = mock_authority_verification - mock.verify_username = mock_username_verification - mock.verify_destination_args = mock_destination_args_verification - return mock - - -@pytest.fixture -def mock_cli_args(): - return Namespace() - - -@pytest.fixture -def mock_config_args(): - return {} - - -@pytest.fixture -def mock_config_arg_parser(mocker, mock_config_args): - mock_parser = mocker.patch("c42seceventcli.common.util.get_config_args") - mock_parser.return_value = mock_config_args - return mock_parser - - -@pytest.fixture -def mock_cli_arg_parser(mocker, mock_cli_args): - mock_parser = mocker.patch("argparse.ArgumentParser.parse_args") - mock_parser.return_value = mock_cli_args - return mock_parser - - -@pytest.fixture -def mock_authority_verification(mocker): - return mocker.patch("c42seceventcli.aed.args.AEDArgs.verify_authority_arg") - - -@pytest.fixture -def mock_username_verification(mocker): - return mocker.patch("c42seceventcli.aed.args.AEDArgs.verify_username_arg") - - -@pytest.fixture -def mock_destination_args_verification(mocker): - return mocker.patch("c42seceventcli.aed.args.AEDArgs.verify_destination_args") - - -def test_get_args_calls_sec_args_try_set_with_expected_args( - mocker, patches_with_mocked_args_verifications -): - mock_setter = mocker.patch("c42seceventcli.common.util.SecArgs.try_set") - key = "c42_authority_url" - expected_cli_val = "URL1" - expected_config_val = "URL2" - patches_with_mocked_args_verifications.cli_args.c42_authority_url = expected_cli_val - patches_with_mocked_args_verifications.config_args[key] = expected_config_val - get_args() - mock_setter.assert_called_once_with(key, expected_cli_val, expected_config_val) - - -def test_get_args_when_destination_is_not_none_and_destination_type_is_stdout_raises_value_error( - patches -): - patches.cli_args.destination_type = "stdout" - patches.cli_args.destination = "Delaware" - with pytest.raises(ValueError): - get_args() - - -def test_get_args_when_destination_is_none_and_destination_type_is_server_raises_value_error( - patches -): - patches.cli_args.destination_type = "server" - patches.cli_args.destination = None - with pytest.raises(ValueError): - get_args() - - -def test_get_args_when_destination_is_none_and_destination_type_is_file_raises_value_error(patches): - patches.cli_args.destination_type = "file" - patches.cli_args.destination = None - with pytest.raises(ValueError): - get_args() diff --git a/tests/aed/test_cursor_store.py b/tests/aed/test_cursor_store.py deleted file mode 100644 index ab7a086ca..000000000 --- a/tests/aed/test_cursor_store.py +++ /dev/null @@ -1,81 +0,0 @@ -from c42secevents.extractors import INSERTION_TIMESTAMP_FIELD_NAME -from c42seceventcli.aed.cursor_store import AEDCursorStore -from tests.conftest import MOCK_TEST_DB_PATH - - -class TestAEDCursorStore(object): - def test_reset_executes_expected_drop_table_query(self, sqlite_connection): - store = AEDCursorStore(MOCK_TEST_DB_PATH) - store.reset() - with store._connection as conn: - actual = conn.execute.call_args_list[0][0][0] - expected = "DROP TABLE aed_checkpoint" - assert actual == expected - - def test_reset_executes_expected_create_table_query(self, sqlite_connection): - store = AEDCursorStore(MOCK_TEST_DB_PATH) - store.reset() - with store._connection as conn: - actual = conn.execute.call_args_list[1][0][0] - expected = "CREATE TABLE aed_checkpoint (cursor_id, insertionTimestamp)" - assert actual == expected - - def test_reset_executes_expected_insert_query(self, sqlite_connection): - store = AEDCursorStore(MOCK_TEST_DB_PATH) - store._connection = sqlite_connection - store.reset() - with store._connection as conn: - actual = conn.execute.call_args[0][0] - expected = "INSERT INTO aed_checkpoint VALUES(?, null)" - assert actual == expected - - def test_reset_executes_query_with_expected_primary_key(self, sqlite_connection): - store = AEDCursorStore(MOCK_TEST_DB_PATH) - store._connection = sqlite_connection - store.reset() - with store._connection as conn: - actual = conn.execute.call_args[0][1][0] - expected = store._PRIMARY_KEY - assert actual == expected - - def test_get_stored_insertion_timestamp_executes_expected_select_query(self, sqlite_connection): - store = AEDCursorStore(MOCK_TEST_DB_PATH) - store.get_stored_insertion_timestamp() - with store._connection as conn: - expected = "SELECT {0} FROM aed_checkpoint WHERE cursor_id=?".format( - INSERTION_TIMESTAMP_FIELD_NAME - ) - actual = conn.cursor().execute.call_args[0][0] - assert actual == expected - - def test_get_stored_insertion_timestamp_executes_query_with_expected_primary_key( - self, sqlite_connection - ): - store = AEDCursorStore(MOCK_TEST_DB_PATH) - store.get_stored_insertion_timestamp() - with store._connection as conn: - actual = conn.cursor().execute.call_args[0][1][0] - expected = store._PRIMARY_KEY - assert actual == expected - - def test_replace_stored_insertion_timestamp_executes_expected_update_query( - self, sqlite_connection - ): - store = AEDCursorStore(MOCK_TEST_DB_PATH) - store.replace_stored_insertion_timestamp(123) - with store._connection as conn: - expected = "UPDATE aed_checkpoint SET {0}=? WHERE cursor_id=?".format( - INSERTION_TIMESTAMP_FIELD_NAME - ) - actual = conn.execute.call_args[0][0] - assert actual == expected - - def test_replace_stored_insertion_timestamp_executes_query_with_expected_primary_key( - self, sqlite_connection - ): - store = AEDCursorStore(MOCK_TEST_DB_PATH) - new_insertion_timestamp = 123 - store.replace_stored_insertion_timestamp(new_insertion_timestamp) - with store._connection as conn: - actual = conn.execute.call_args[0][1][0] - assert actual == new_insertion_timestamp diff --git a/tests/aed/test_main.py b/tests/aed/test_main.py deleted file mode 100644 index 95c0a59b9..000000000 --- a/tests/aed/test_main.py +++ /dev/null @@ -1,315 +0,0 @@ -import pytest -from datetime import datetime, timedelta -from socket import herror, gaierror, timeout - -from py42 import settings -import py42.debug_level as debug_level -from c42secevents.logging.formatters import AEDDictToCEFFormatter, AEDDictToJSONFormatter -from c42seceventcli.aed.cursor_store import AEDCursorStore -from c42seceventcli.aed.args import AEDArgs - -from c42seceventcli.aed import main - - -@pytest.fixture -def patches( - mocker, - mock_aed_extractor_constructor, - mock_aed_extractor, - mock_store, - mock_42, - mock_args, - mock_args_getter, - mock_password_getter, - mock_password_deleter, - mock_logger, - mock_error_logger, - mock_cursor_reset_function, -): - mock = mocker.MagicMock() - mock.aed_extractor_constructor = mock_aed_extractor_constructor - mock.aed_extractor = mock_aed_extractor - mock.store = mock_store - mock.py42 = mock_42 - mock.aed_args = mock_args - mock.args_getter = mock_args_getter - mock.get_password = mock_password_getter - mock.delete_password = mock_password_deleter - mock.get_logger = mock_logger - mock.error_logger = mock_error_logger - mock.reset_cursor = mock_cursor_reset_function - return mock - - -@pytest.fixture -def mock_aed_extractor_constructor(mocker): - mock = mocker.patch("c42secevents.extractors.AEDEventExtractor.__init__") - mock.return_value = None - return mock - - -@pytest.fixture -def mock_aed_extractor(mocker): - return mocker.patch("c42secevents.extractors.AEDEventExtractor.extract") - - -@pytest.fixture -def mock_store(mocker): - store = mocker.patch("c42seceventcli.aed.main.AEDCursorStore.__init__") - store.return_value = None - return store - - -@pytest.fixture -def mock_42(mocker): - settings.verify_ssl_certs = True - settings.debug_level = debug_level.NONE - return mocker.patch("py42.sdk.SDK.create_using_local_account") - - -@pytest.fixture -def mock_args(mocker, mock_args_getter): - args = AEDArgs() - args.cli_parser = mocker.MagicMock() - args.c42_authority_url = "https://example.com" - args.c42_username = "test.testerson@example.com" - mock_args_getter.return_value = args - return args - - -@pytest.fixture -def mock_args_getter(mocker): - return mocker.patch("c42seceventcli.aed.args.get_args") - - -@pytest.fixture -def mock_password_getter(mocker): - mock = mocker.patch("c42seceventcli.common.util.get_stored_password") - mock.get_password.return_value = "PASSWORD" - return mock - - -@pytest.fixture -def mock_password_deleter(mocker): - return mocker.patch("c42seceventcli.common.util.delete_stored_password") - - -@pytest.fixture -def mock_logger(mocker): - return mocker.patch("c42seceventcli.common.util.get_logger") - - -@pytest.fixture -def mock_error_logger(mocker): - return mocker.patch("c42seceventcli.common.util.get_error_logger") - - -@pytest.fixture -def mock_cursor_reset_function(mocker): - return mocker.patch("c42seceventcli.aed.main.AEDCursorStore.reset") - - -def test_main_when_get_args_raises_value_error_causes_system_exit(patches): - patches.args_getter.side_effect = ValueError - with pytest.raises(SystemExit): - main.main() - - -def test_main_when_ignore_ssl_errors_is_true_that_py42_settings_verify_ssl_certs_is_false(patches): - patches.aed_args.ignore_ssl_errors = True - main.main() - assert not settings.verify_ssl_certs - - -def test_main_when_ignore_ssl_errors_is_false_that_py42_settings_verify_ssl_certs_is_true(patches): - patches.args.ignore_ssl_errors = False - main.main() - assert settings.verify_ssl_certs - - -def test_main_when_reset_password_is_true_calls_delete_password(patches): - expected_username = "Bob" - patches.aed_args.c42_username = expected_username - patches.aed_args.reset_password = True - main.main() - patches.delete_password.assert_called_once_with(main._SERVICE_NAME, expected_username) - - -def test_main_when_reset_password_is_false_does_not_call_delete_password(patches): - patches.aed_args.reset_password = False - main.main() - assert not patches.delete_password.call_count - - -def test_main_when_clear_cursor_is_true_calls_aed_cursor_store_reset(patches): - patches.aed_args.record_cursor = True - patches.aed_args.clear_cursor = True - main.main() - assert patches.reset_cursor.call_count == 1 - - -def test_main_when_clear_cursor_is_false_does_not_call_aed_cursor_store_reset(patches): - patches.aed_args.record_cursor = True - patches.aed_args.clear_cursor = False - main.main() - assert not patches.reset_cursor.call_count - - -def test_main_when_debug_mode_is_true_that_py42_settings_debug_mode_is_debug(patches): - patches.aed_args.debug_mode = True - main.main() - assert settings.debug_level == debug_level.DEBUG - - -def test_main_uses_min_timestamp_from_sixty_days_ago(patches): - main.main() - expected = ( - (datetime.now() - timedelta(days=60)) - datetime.utcfromtimestamp(0) - ).total_seconds() - actual = patches.aed_extractor.call_args[0][0] - assert pytest.approx(expected, actual) - - -def test_main_uses_max_timestamp_from_now(patches): - main.main() - expected = (datetime.now() - datetime.utcfromtimestamp(0)).total_seconds() - actual = patches.aed_extractor.call_args[0][1] - assert pytest.approx(expected, actual) - - -def test_main_when_create_sdk_raises_exception_causes_exit(patches): - patches.py42.side_effect = Exception - with pytest.raises(SystemExit): - main.main() - - -def test_main_creates_sdk_with_args_and_password_from_get_password(patches): - expected_authority = "https://user.authority.com" - expected_username = "user.userson@userson.solutions" - expected_password = "querty" - patches.aed_args.c42_authority_url = expected_authority - patches.aed_args.c42_username = expected_username - patches.get_password.return_value = expected_password - main.main() - patches.py42.assert_called_once_with( - host_address=expected_authority, username=expected_username, password=expected_password - ) - - -def test_main_when_output_format_not_supported_causes_exit(patches): - patches.aed_args.output_format = "EAS3" - with pytest.raises(SystemExit): - main.main() - - -def test_main_when_output_format_is_json_creates_json_formatter(patches): - patches.aed_args.output_format = "JSON" - main.main() - expected = AEDDictToJSONFormatter - actual = type(patches.get_logger.call_args[1]["formatter"]) - assert actual == expected - - -def test_main_when_output_format_is_cef_creates_cef_formatter(patches): - patches.aed_args.output_format = "CEF" - main.main() - expected = AEDDictToCEFFormatter - actual = type(patches.get_logger.call_args[1]["formatter"]) - assert actual == expected - - -def test_main_when_destination_port_is_set_passes_port_to_get_logger(patches): - expected = 1000 - patches.aed_args.destination_port = expected - main.main() - actual = patches.get_logger.call_args[1]["destination_args"].destination_port - assert actual == expected - - -def test_main_when_given_destination_protocol_via_cli_passes_port_to_get_logger(patches): - expected = "SOME PROTOCOL" - patches.aed_args.destination_protocol = expected - main.main() - actual = patches.get_logger.call_args[1]["destination_args"].destination_protocol - assert actual == expected - - -def test_main_when_get_logger_raises_io_error_without_errno_61_print_error_about_file_path( - patches, capsys -): - patches.get_logger.side_effect = IOError - with pytest.raises(SystemExit): - main.main() - - assert "file path" in capsys.readouterr().out.lower() - - -def test_main_when_get_logger_raises_io_error_with_errno_61_prints_error_about_hostname( - patches, capsys -): - err = IOError() - err.errno = 61 - patches.get_logger.side_effect = err - - with pytest.raises(SystemExit): - main.main() - - assert "hostname" in capsys.readouterr().out.lower() - - -def test_main_when_get_logger_raises_h_error_causes_exit(patches): - patches.get_logger.side_effect = gaierror - with pytest.raises(SystemExit): - main.main() - - -def test_main_when_get_logger_raises_gai_error_causes_exit(patches): - patches.get_logger.side_effect = herror - with pytest.raises(SystemExit): - main.main() - - -def test_main_when_get_logger_raises_timeout_causes_exit(patches): - patches.get_logger.side_effect = timeout - with pytest.raises(SystemExit): - main.main() - - -def test_main_when_record_cursor_is_true_overrides_handlers_record_cursor_position(mocker, patches): - expected = mocker.MagicMock() - AEDCursorStore.replace_stored_insertion_timestamp = expected - patches.aed_args.record_cursor = True - main.main() - actual = patches.aed_extractor_constructor.call_args[0][1].record_cursor_position - assert actual is expected - - -def test_main_when_record_cursor_is_false_does_not_override_handlers_record_cursor_position( - mocker, patches -): - unexpected = mocker.MagicMock() - AEDCursorStore.replace_stored_insertion_timestamp = unexpected - patches.aed_args.record_cursor = False - main.main() - actual = patches.aed_extractor_constructor.call_args[0][1].record_cursor_position - assert actual is not unexpected - - -def test_main_when_record_cursor_is_true_overrides_handlers_get_cursor_position(mocker, patches): - expected = mocker.MagicMock() - AEDCursorStore.get_stored_insertion_timestamp = expected - patches.aed_args.record_cursor = True - main.main() - actual = patches.aed_extractor_constructor.call_args[0][1].get_cursor_position - assert actual is expected - - -def test_main_when_record_cursor_is_false_does_not_override_handlers_get_cursor_position( - mocker, patches -): - unexpected = mocker.MagicMock() - AEDCursorStore.get_stored_insertion_timestamp = unexpected - patches.aed_args.record_cursor = False - main.main() - actual = patches.aed_extractor_constructor.call_args[0][1].get_cursor_position - assert actual is not unexpected diff --git a/tests/common/__init__.py b/tests/common/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/common/test_common.py b/tests/common/test_common.py deleted file mode 100644 index dc71c1064..000000000 --- a/tests/common/test_common.py +++ /dev/null @@ -1,322 +0,0 @@ -import pytest -from os import path -from datetime import datetime, timedelta -from logging import StreamHandler, FileHandler - -from c42secevents.logging.handlers import NoPrioritySysLogHandler - -from c42seceventcli.common.util import ( - get_config_args, - parse_timestamp, - get_logger, - get_error_logger, - SecArgs, - get_stored_password, - delete_stored_password, - get_user_project_path, - DestinationArgs, -) - - -_DUMMY_KEY = "Key" - - -@pytest.fixture -def mock_config_read(mocker): - return mocker.patch("configparser.ConfigParser.read") - - -@pytest.fixture -def mock_config_get_function(mocker): - return mocker.patch("configparser.ConfigParser.get") - - -@pytest.fixture -def mock_config_get_bool_function(mocker): - return mocker.patch("configparser.ConfigParser.getboolean") - - -@pytest.fixture -def mock_config_file_reader(mocker): - reader = mocker.patch("configparser.ConfigParser.read") - reader.return_value = ["NOT EMPTY LIST"] - return reader - - -@pytest.fixture -def mock_config_file_sections(mocker): - sections = mocker.patch("configparser.ConfigParser.sections") - sections.return_value = ["NOT EMPTY LIST"] - return sections - - -@pytest.fixture -def mock_get_logger(mocker): - return mocker.patch("logging.getLogger") - - -@pytest.fixture -def mock_no_priority_syslog_handler(mocker): - mock_handler_init = mocker.patch( - "c42secevents.logging.handlers.NoPrioritySysLogHandler.__init__" - ) - mock_handler_init.return_value = None - return mock_handler_init - - -@pytest.fixture -def mock_file_handler(mocker): - mock_handler_init = mocker.patch("logging.FileHandler.__init__") - mock_handler_init.return_value = None - return mock_handler_init - - -@pytest.fixture -def mock_password_getter(mocker): - return mocker.patch("keyring.get_password") - - -@pytest.fixture -def password_patches( - mocker, - mock_password_getter, - mock_password_setter, - mock_password_deleter, - mock_get_input, - mock_getpass_function, -): - mock = mocker.MagicMock() - mock.get_password = mock_password_getter - mock.set_password = mock_password_setter - mock.delete_password = mock_password_deleter - mock.getpass = mock_getpass_function - mock.get_input = mock_get_input - return mock - - -@pytest.fixture -def mock_password_setter(mocker): - return mocker.patch("keyring.set_password") - - -@pytest.fixture -def mock_password_deleter(mocker): - return mocker.patch("keyring.delete_password") - - -@pytest.fixture -def mock_getpass_function(mocker): - return mocker.patch("getpass.getpass") - - -@pytest.fixture -def mock_get_input(mocker): - return mocker.patch("c42seceventcli.common.util._get_input") - - -@pytest.fixture -def path_patches(mocker, mock_user_expansion, mock_dir_maker, mock_path_existence): - mock = mocker.MagicMock() - mock.expand_user = mock_user_expansion - mock.make_dirs = mock_dir_maker - mock.path_exists = mock_path_existence - return mock - - -@pytest.fixture -def mock_user_expansion(mocker): - return mocker.patch("os.path.expanduser") - - -@pytest.fixture -def mock_dir_maker(mocker): - return mocker.patch("c42seceventcli.common.util.makedirs") - - -@pytest.fixture -def mock_path_existence(mocker): - return mocker.patch("os.path.exists") - - -def test_get_user_project_path_returns_expected_path(path_patches): - expected_home = "/PATH/" - expected_subdir = "SUBDIR" - path_patches.expand_user.return_value = expected_home - expected = path.join(expected_home, ".c42seceventcli", expected_subdir) - actual = get_user_project_path(expected_subdir) - assert actual == expected - - -def test_get_user_project_path_calls_make_dirs_when_path_does_not_exist(path_patches): - expected_home = "/PATH/" - expected_subdir = "SUBDIR" - path_patches.expand_user.return_value = expected_home - expected_path = path.join(expected_home, ".c42seceventcli", expected_subdir) - path_patches.path_exists.return_value = False - get_user_project_path(expected_subdir) - path_patches.make_dirs.assert_called_once_with(expected_path) - - -def test_get_user_project_path_does_not_call_make_dirs_when_path_exists(path_patches): - expected_home = "/PATH/" - expected_subdir = "SUBDIR" - path_patches.expand_user.return_value = expected_home - path_patches.path_exists.return_value = True - get_user_project_path(expected_subdir) - assert not path_patches.make_dirs.call_count - - -def test_get_config_args_when_read_returns_empty_list_raises_io_error(mocker): - reader = mocker.patch("configparser.ConfigParser.read") - reader.return_value = [] - with pytest.raises(IOError): - get_config_args("Test") - - -def test_get_config_args_when_sections_returns_empty_list_returns_empty_dict( - mocker, mock_config_file_reader -): - sections = mocker.patch("configparser.ConfigParser.sections") - sections.return_value = [] - assert get_config_args("Test") == {} - - -def test_get_config_args_returns_dict_made_from_items( - mocker, mock_config_file_reader, mock_config_file_sections -): - mock_tuples = mocker.patch("configparser.ConfigParser.items") - mock_tuples.return_value = [("Hi", "Bye"), ("Pizza", "FrenchFries")] - arg_dict = get_config_args("Test") - assert arg_dict == {"Hi": "Bye", "Pizza": "FrenchFries"} - - -def test_parse_timestamp_when_given_date_format_returns_expected_timestamp(): - date_str = "2019-10-01" - date = datetime.strptime(date_str, "%Y-%m-%d") - expected = (date - date.utcfromtimestamp(0)).total_seconds() - actual = parse_timestamp(date_str) - assert actual == expected - - -def test_parse_timestamp_when_given_minutes_ago_format_returns_expected_timestamp(): - minutes_ago = 1000 - now = datetime.utcnow() - time = now - timedelta(minutes=minutes_ago) - expected = (time - datetime.utcfromtimestamp(0)).total_seconds() - actual = parse_timestamp("1000") - assert pytest.approx(actual, expected) - - -def test_parse_timestamp_when_given_bad_string_raises_value_error(): - with pytest.raises(ValueError): - parse_timestamp("BAD!") - - -def test_get_error_logger_uses_rotating_file_with_expected_args(mocker, mock_get_logger): - expected_service_name = "TEST_SERVICE" - mock_handler = mocker.patch("logging.handlers.RotatingFileHandler.__init__") - mock_handler.return_value = None - get_error_logger(expected_service_name) - expected_path = "{0}/{1}_errors.log".format(get_user_project_path("log"), expected_service_name) - mock_handler.assert_called_once_with(expected_path, maxBytes=250000000) - - -def test_get_logger_when_destination_type_is_stdout_adds_stream_handler_to_logger(mock_get_logger): - service = "TEST_SERVICE" - args = DestinationArgs() - args.destination_type = "stdout" - logger = get_logger(None, service, args) - actual = type(logger.addHandler.call_args[0][0]) - expected = StreamHandler - assert actual == expected - - -def test_get_logger_when_destination_type_is_file_adds_file_handler_to_logger( - mock_get_logger, mock_file_handler -): - service = "TEST_SERVICE" - args = DestinationArgs() - args.destination_type = "file" - logger = get_logger(None, service, args) - actual = type(logger.addHandler.call_args[0][0]) - expected = FileHandler - assert actual == expected - - -def test_get_logger_when_destination_type_is_server_adds_no_priority_syslog_handler_to_logger( - mock_get_logger, mock_no_priority_syslog_handler -): - service = "TEST_SERVICE" - args = DestinationArgs() - args.destination_type = "server" - logger = get_logger(None, service, args) - actual = type(logger.addHandler.call_args[0][0]) - expected = NoPrioritySysLogHandler - assert actual == expected - - -def test_get_stored_password_when_keyring_returns_none_uses_password_from_getpass(password_patches): - password_patches.get_password.return_value = None - expected = "super_secret_password" - password_patches.getpass.return_value = expected - actual = get_stored_password("TEST", "USER") - assert actual == expected - - -def test_get_stored_password_returns_same_value_from_keyring(password_patches): - expected = "super_secret_password" - password_patches.get_password.return_value = expected - actual = get_stored_password("TEST", "USER") - assert actual == expected - - -def test_get_stored_password_when_keyring_returns_none_and_get_input_returns_y_calls_set_password_with_password_from_getpass( - password_patches -): - expected_service_name = "SERVICE" - expected_username = "ME" - expected_password = "super_secret_password" - password_patches.get_password.return_value = None - password_patches.get_input.return_value = "y" - password_patches.getpass.return_value = expected_password - - get_stored_password(expected_service_name, expected_username) - password_patches.set_password.assert_called_once_with( - expected_service_name, expected_username, expected_password - ) - - -def test_get_stored_password_when_keyring_returns_none_and_get_input_returns_n_does_not_call_set_password( - password_patches -): - expected_service_name = "SERVICE" - expected_username = "ME" - expected_password = "super_secret_password" - password_patches.get_password.return_value = expected_username - password_patches.get_input.return_value = "n" - password_patches.getpass.return_value = expected_password - - get_stored_password(expected_service_name, expected_username) - assert not password_patches.set_password.call_count - - -def test_delete_stored_password_calls_keyring_delete_password(password_patches): - expected_service_name = "SERVICE" - expected_username = "ME" - delete_stored_password(expected_service_name, expected_username) - password_patches.delete_password.assert_called_once_with( - expected_service_name, expected_username - ) - - -def test_subclass_of_sec_args_try_set_favors_cli_arg_over_config_arg(): - class SubclassSecArgs(SecArgs): - test = None - - arg_name = "test" - cli_arg_value = 1 - config_arg_value = 2 - args = SubclassSecArgs() - args.try_set(arg_name, cli_arg_value, config_arg_value) - expected = cli_arg_value - assert args.test == expected diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 664988c12..000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -MOCK_TEST_DB_PATH = "test_path.db" - - -@pytest.fixture -def sqlite_connection(mocker): - return mocker.patch("sqlite3.connect") diff --git a/tests/aed/__init__.py b/tests/profile/__init__.py similarity index 100% rename from tests/aed/__init__.py rename to tests/profile/__init__.py diff --git a/tests/profile/conftest.py b/tests/profile/conftest.py new file mode 100644 index 000000000..57a8268b8 --- /dev/null +++ b/tests/profile/conftest.py @@ -0,0 +1,33 @@ +import pytest +from c42sec.profile._config import ConfigurationKeys + + +@pytest.fixture +def config_profile(mocker): + mock_config = mocker.patch("c42sec.profile._config.get_config_profile") + mock_config.return_value = { + ConfigurationKeys.USERNAME_KEY: "test.username", + ConfigurationKeys.AUTHORITY_KEY: "https://authority.example.com", + ConfigurationKeys.IGNORE_SSL_ERRORS_KEY: "True", + } + return mock_config + + +@pytest.fixture +def config_parser(mocker): + mocks = ConfigParserMocks() + mocks.initializer = mocker.patch("configparser.ConfigParser.__init__") + mocks.item_setter = mocker.patch("configparser.ConfigParser.__setitem__") + mocks.item_getter = mocker.patch("configparser.ConfigParser.__getitem__") + mocks.section_adder = mocker.patch("configparser.ConfigParser.add_section") + mocks.reader = mocker.patch("configparser.ConfigParser.read") + mocks.initializer.return_value = None + return mocks + + +class ConfigParserMocks(object): + initializer = None + item_setter = None + item_getter = None + section_adder = None + reader = None diff --git a/tests/profile/test_config.py b/tests/profile/test_config.py new file mode 100644 index 000000000..c40c16e5f --- /dev/null +++ b/tests/profile/test_config.py @@ -0,0 +1,124 @@ +import pytest + +import c42sec.profile._config as config + + +@pytest.fixture +def path_exists_function(mocker): + return mocker.patch("os.path.exists") + + +@pytest.fixture +def path_exists(path_exists_function): + path_exists_function.return_value = True + return path_exists_function + + +@pytest.fixture +def path_does_not_exist(path_exists_function): + path_exists_function.return_value = False + return path_exists_function + + +@pytest.fixture +def non_existent_profile(mocker, config_parser): + config_parser.item_getter.return_value = create_config_profile(mocker, False) + return config_parser + + +@pytest.fixture +def existent_profile(mocker, config_parser): + config_parser.item_getter.return_value = create_config_profile(mocker, True) + return config_parser + + +@pytest.fixture +def mock_project_path(mocker): + project_path_getter = mocker.patch("c42sec.util.get_user_project_path") + project_path_getter.return_value = "some/path/" + return project_path_getter + + +@pytest.fixture +def open_file_function(mocker): + open_file = mocker.patch("c42sec.util.open_file") + new_file = mocker.MagicMock() + open_file.return_value = new_file + return open_file + + +def create_config_profile(mocker, is_set): + config_profile = mocker.MagicMock() + bool_getter = mocker.MagicMock() + bool_getter.return_value = is_set + config_profile.getboolean = bool_getter + config_profile.__setitem__ = mocker.MagicMock() + return config_profile + + +def assert_save_was_called(open_file_function): + call_args = open_file_function.call_args + assert call_args[0][0] == "some/path/config.cfg" and call_args[0][1] == "w+" + + +def test_get_config_profile_when_file_exists_but_profile_does_not_exist_exits( + path_exists, non_existent_profile, mock_project_path +): + # It is expected to exit because the user must set their profile before they can see it. + with pytest.raises(SystemExit): + config.get_config_profile() + + +def test_get_config_profile_when_file_exists_and_profile_is_set_does_not_exit( + path_exists, existent_profile, mock_project_path +): + # Presumably, it shows the profile instead of exiting. + assert config.get_config_profile() + + +def test_get_config_profile_when_path_does_not_exist_saves_changes( + path_does_not_exist, open_file_function, mock_project_path, non_existent_profile +): + with pytest.raises(SystemExit): + config.get_config_profile() + + # It saves because it is writing default values to the config file + assert_save_was_called(open_file_function) + + +def test_mark_as_set_saves_changes(existent_profile, open_file_function, mock_project_path): + config.mark_as_set() + assert_save_was_called(open_file_function) + + +def test_profile_has_been_set_when_when_getboolean_returns_true_returns_true( + existent_profile, open_file_function +): + assert config.profile_has_been_set() + + +def test_profile_has_been_set_when_when_getboolean_returns_false_returns_false( + non_existent_profile, open_file_function +): + assert not config.profile_has_been_set() + + +def test_set_username_saves( + path_does_not_exist, open_file_function, mock_project_path, existent_profile +): + config.set_username("New user") + assert_save_was_called(open_file_function) + + +def test_set_authority_url_saves( + path_does_not_exist, open_file_function, mock_project_path, existent_profile +): + config.set_authority_url("new url") + assert_save_was_called(open_file_function) + + +def test_set_ignore_ssl_errors_saves( + path_does_not_exist, open_file_function, mock_project_path, existent_profile +): + config.set_ignore_ssl_errors(True) + assert_save_was_called(open_file_function) diff --git a/tests/profile/test_password.py b/tests/profile/test_password.py new file mode 100644 index 000000000..e42e50179 --- /dev/null +++ b/tests/profile/test_password.py @@ -0,0 +1,62 @@ +import pytest +import c42sec.profile._password as password + + +@pytest.fixture +def keyring_password_getter(mocker): + return mocker.patch("keyring.get_password") + + +@pytest.fixture +def keyring_password_setter(mocker): + return mocker.patch("keyring.set_password") + + +@pytest.fixture +def getpass_function(mocker): + return mocker.patch("c42sec.profile._password.getpass") + + +def test_get_password_uses_expected_service_name_and_username( + keyring_password_getter, config_profile +): + password.get_password() + # See conftest.config_profile + expected_service_name = "c42sec::https://authority.example.com" + expected_username = "test.username" + keyring_password_getter.assert_called_once_with(expected_service_name, expected_username) + + +def test_get_password_when_password_is_none_returns_password_from_getpass( + keyring_password_getter, config_profile, getpass_function +): + keyring_password_getter.return_value = None + getpass_function.return_value = "test password" + assert password.get_password() == "test password" + + +def test_get_password_when_password_is_not_none_returns_password( + keyring_password_getter, config_profile, getpass_function +): + keyring_password_getter.return_value = "already stored password 123" + assert password.get_password() == "already stored password 123" + + +def test_get_password_when_password_is_none_and_told_to_not_prompt_if_not_exists_returns_none( + keyring_password_getter, config_profile, getpass_function +): + keyring_password_getter.return_value = None + assert password.get_password(prompt_if_not_exists=False) is None + + +def test_set_password_uses_expected_service_name_username_and_password( + keyring_password_setter, config_profile, getpass_function +): + getpass_function.return_value = "test password" + password.set_password() + # See conftest.config_profile + expected_service_name = "c42sec::https://authority.example.com" + expected_username = "test.username" + keyring_password_setter.assert_called_once_with( + expected_service_name, expected_username, "test password" + ) diff --git a/tests/profile/test_profile.py b/tests/profile/test_profile.py new file mode 100644 index 000000000..33f2f3489 --- /dev/null +++ b/tests/profile/test_profile.py @@ -0,0 +1,221 @@ +import pytest +from argparse import ArgumentParser +from c42sec.profile import profile + + +@pytest.fixture +def username_setter(mocker): + return mocker.patch("c42sec.profile._config.set_username") + + +@pytest.fixture +def mark_as_set_function(mocker): + return mocker.patch("c42sec.profile._config.mark_as_set") + + +@pytest.fixture +def authority_url_setter(mocker): + return mocker.patch("c42sec.profile._config.set_authority_url") + + +@pytest.fixture +def ignore_ssl_errors_setter(mocker): + return mocker.patch("c42sec.profile._config.set_ignore_ssl_errors") + + +@pytest.fixture +def password_setter(mocker): + return mocker.patch("c42sec.profile._password.set_password") + + +@pytest.fixture +def password_getter(mocker): + return mocker.patch("c42sec.profile._password.get_password") + + +@pytest.fixture +def profile_not_set_state(mocker): + profile_verifier = mocker.patch("c42sec.profile._config.profile_has_been_set") + profile_verifier.return_value = False + return profile_verifier + + +@pytest.fixture +def profile_is_set_state(mocker): + profile_verifier = mocker.patch("c42sec.profile._config.profile_has_been_set") + profile_verifier.return_value = True + return profile_verifier + + +def test_init_adds_profile_subcommand_to_choices(config_parser): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + assert subcommand_parser.choices.get("profile") + + +def test_init_adds_parser_that_can_parse_show_command(config_parser): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + profile_parser = subcommand_parser.choices.get("profile") + assert profile_parser.parse_args(["show"]) + + +def test_init_adds_parser_that_can_parse_set_command(config_parser): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + profile_parser = subcommand_parser.choices.get("profile") + + # Commands that require a value will fail here if not provided + assert profile_parser.parse_args( + ["set", "-s", "server-arg", "-p", "-u", "username-arg", "--enable-ssl-errors"] + ) + + +def test_get_profile_returns_object_from_config_file(config_parser, config_profile): + user = profile.get_profile() + + # Values from config_file fixture + assert ( + user.username == "test.username" + and user.authority_url == "https://authority.example.com" + and user.ignore_ssl_errors + ) + + +def test_set_profile_when_given_username_sets_username( + config_parser, username_setter, password_getter, profile_is_set_state +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) + profile.set_profile(namespace) + username_setter.assert_called_once_with("a.new.user@example.com") + + +def test_set_profile_when_given_authority_url_sets_authority_url( + config_parser, authority_url_setter, profile_is_set_state, password_getter +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "-s", "https://wwww.new.authority.example.com"]) + profile.set_profile(namespace) + authority_url_setter.assert_called_once_with("https://wwww.new.authority.example.com") + + +def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true( + config_parser, ignore_ssl_errors_setter, profile_is_set_state, password_getter +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "--enable-ssl-errors"]) + profile.set_profile(namespace) + ignore_ssl_errors_setter.assert_called_once_with(False) + + +def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_false( + config_parser, ignore_ssl_errors_setter, profile_is_set_state, password_getter +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "--disable-ssl-errors"]) + profile.set_profile(namespace) + ignore_ssl_errors_setter.assert_called_once_with(True) + + +def test_set_profile_when_is_first_time_and_given_username_but_not_given_authority_url_fails( + username_setter, profile_not_set_state +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) + with pytest.raises(SystemExit): + profile.set_profile(namespace) + + +def test_set_profile_when_is_first_time_and_given_username_but_not_given_authority_url_does_not_set( + config_parser, username_setter, profile_not_set_state +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) + with pytest.raises(SystemExit): + profile.set_profile(namespace) + + assert username_setter.call_args is None + + +def test_set_profile_when_is_first_time_and_given_authority_url_but_not_given_username_fails( + config_parser, authority_url_setter, profile_not_set_state +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "-s", "https://wwww.new.authority.example.com"]) + with pytest.raises(SystemExit): + profile.set_profile(namespace) + + +def test_set_profile_when_is_first_time_and_given_authority_url_but_not_given_username_does_not_set( + config_parser, authority_url_setter, profile_not_set_state +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "-s", "https://wwww.new.authority.example.com"]) + with pytest.raises(SystemExit): + profile.set_profile(namespace) + + assert authority_url_setter.call_args is None + + +def test_set_profile_when_is_first_time_and_given_both_authority_and_username_sets_username( + config_parser, + profile_not_set_state, + username_setter, + authority_url_setter, + password_getter, + mark_as_set_function, +): + parser = _get_profile_parser() + namespace = parser.parse_args( + ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] + ) + profile.set_profile(namespace) + username_setter.assert_called_once_with("user") + + +def test_set_profile_when_is_first_time_and_given_both_authority_and_username_sets_authority_url( + config_parser, + profile_not_set_state, + username_setter, + authority_url_setter, + password_getter, + mark_as_set_function, +): + parser = _get_profile_parser() + namespace = parser.parse_args( + ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] + ) + profile.set_profile(namespace) + authority_url_setter.assert_called_once_with("https://wwww.new.authority.example.com") + + +def test_set_profile_when_is_first_time_and_given_both_authority_and_username_marks_as_set( + config_parser, + profile_not_set_state, + username_setter, + authority_url_setter, + password_getter, + mark_as_set_function, +): + parser = _get_profile_parser() + namespace = parser.parse_args( + ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] + ) + profile.set_profile(namespace) + mark_as_set_function.assert_called_once_with() + + +def test_set_profile_when_given_password_sets_password( + config_parser, password_setter, password_getter, profile_is_set_state +): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "-p"]) + profile.set_profile(namespace) + assert len(password_setter.call_args) > 0 + + +def _get_profile_parser(): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + return subcommand_parser.choices.get("profile") diff --git a/tests/common/test_cursor_store.py b/tests/test_cursor_store.py similarity index 76% rename from tests/common/test_cursor_store.py rename to tests/test_cursor_store.py index 624f04ab9..7434af8b9 100644 --- a/tests/common/test_cursor_store.py +++ b/tests/test_cursor_store.py @@ -1,5 +1,14 @@ from os import path -from c42seceventcli.common.cursor_store import SecurityEventCursorStore + +import pytest +from c42sec.cursor_store import SecurityEventCursorStore + +MOCK_TEST_DB_PATH = "test_path.db" + + +@pytest.fixture +def sqlite_connection(mocker): + return mocker.patch("sqlite3.connect") class TestSecurityEventCursorStore(object): @@ -7,7 +16,7 @@ def test_init_cursor_store_when_not_given_db_file_path_uses_expected_path_with_d self, sqlite_connection ): home_dir = path.expanduser("~") - expected_path = path.join(home_dir, ".c42seceventcli/db") + expected_path = path.join(home_dir, ".c42sec/db") expected_db_name = "TEST" expected_db_file_path = "{0}/{1}.db".format(expected_path, expected_db_name) SecurityEventCursorStore(expected_db_name) From 211a7d6eaced8978f15aa76197bcde1cff5e2052 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Tue, 25 Feb 2020 16:22:38 -0600 Subject: [PATCH 002/349] Chore/set up automation (#4) --- .coveragerc | 2 + .editorconfig | 14 ++ .github/workflows/build.yml | 22 ++ .github/workflows/publish.yml | 69 ++++++ .gitignore | 3 + CHANGELOG.md | 25 +- CONTRIBUTING.md | 77 +++++++ MANIFEST.in | 1 + README.md | 62 ++++- aed_config.default.cfg | 21 -- c42sec/.DS_Store | Bin 6148 -> 0 bytes c42sec/main.py | 33 --- c42sec/send_to/send_to.py | 7 - c42sec/write_to/write_to.py | 7 - setup.py | 50 +++- {c42sec => src}/__init__.py | 0 src/code42cli/__init__.py | 1 + src/code42cli/__version__.py | 1 + src/code42cli/compat.py | 21 ++ src/code42cli/main.py | 23 ++ {c42sec => src/code42cli}/profile/__init__.py | 0 .../code42cli/profile/config.py | 17 +- .../code42cli/profile/password.py | 10 +- {c42sec => src/code42cli}/profile/profile.py | 56 +++-- .../code42cli/securitydata}/__init__.py | 0 .../securitydata/arguments}/__init__.py | 0 src/code42cli/securitydata/arguments/main.py | 38 ++++ .../securitydata/arguments/search.py | 63 ++++++ .../code42cli/securitydata}/cursor_store.py | 6 +- src/code42cli/securitydata/extraction.py | 132 +++++++++++ src/code42cli/securitydata/logger_factory.py | 76 +++++++ src/code42cli/securitydata/main.py | 11 + src/code42cli/securitydata/options.py | 40 ++++ .../securitydata/subcommands/__init__.py | 0 .../subcommands/clear_checkpoint.py | 22 ++ .../securitydata/subcommands/print_out.py | 22 ++ .../securitydata/subcommands/send_to.py | 41 ++++ .../securitydata/subcommands/write_to.py | 29 +++ {c42sec => src/code42cli}/util.py | 6 +- tests/{profile => }/conftest.py | 20 +- tests/profile/test_config.py | 138 ++++++------ tests/profile/test_password.py | 15 +- tests/profile/test_profile.py | 26 +-- tests/securitydata/__init__.py | 0 tests/securitydata/conftest.py | 2 + tests/securitydata/subcommands/__init__.py | 0 .../subcommands/test_clear_checkpoint.py | 22 ++ .../subcommands/test_print_out.py | 57 +++++ .../securitydata/subcommands/test_send_to.py | 88 ++++++++ .../securitydata/subcommands/test_write_to.py | 87 +++++++ tests/{ => securitydata}/test_cursor_store.py | 22 +- tests/securitydata/test_extraction.py | 213 ++++++++++++++++++ tests/securitydata/test_logger_factory.py | 155 +++++++++++++ tests/test_main.py | 68 ++++++ tox.ini | 49 ++++ 55 files changed, 1735 insertions(+), 235 deletions(-) create mode 100644 .coveragerc create mode 100644 .editorconfig create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish.yml create mode 100644 MANIFEST.in delete mode 100644 aed_config.default.cfg delete mode 100644 c42sec/.DS_Store delete mode 100644 c42sec/main.py delete mode 100644 c42sec/send_to/send_to.py delete mode 100644 c42sec/write_to/write_to.py rename {c42sec => src}/__init__.py (100%) create mode 100644 src/code42cli/__init__.py create mode 100644 src/code42cli/__version__.py create mode 100644 src/code42cli/compat.py create mode 100644 src/code42cli/main.py rename {c42sec => src/code42cli}/profile/__init__.py (100%) rename c42sec/profile/_config.py => src/code42cli/profile/config.py (87%) rename c42sec/profile/_password.py => src/code42cli/profile/password.py (72%) rename {c42sec => src/code42cli}/profile/profile.py (75%) rename {c42sec/send_to => src/code42cli/securitydata}/__init__.py (100%) rename {c42sec/write_to => src/code42cli/securitydata/arguments}/__init__.py (100%) create mode 100644 src/code42cli/securitydata/arguments/main.py create mode 100644 src/code42cli/securitydata/arguments/search.py rename {c42sec => src/code42cli/securitydata}/cursor_store.py (95%) create mode 100644 src/code42cli/securitydata/extraction.py create mode 100644 src/code42cli/securitydata/logger_factory.py create mode 100644 src/code42cli/securitydata/main.py create mode 100644 src/code42cli/securitydata/options.py create mode 100644 src/code42cli/securitydata/subcommands/__init__.py create mode 100644 src/code42cli/securitydata/subcommands/clear_checkpoint.py create mode 100644 src/code42cli/securitydata/subcommands/print_out.py create mode 100644 src/code42cli/securitydata/subcommands/send_to.py create mode 100644 src/code42cli/securitydata/subcommands/write_to.py rename {c42sec => src/code42cli}/util.py (73%) rename tests/{profile => }/conftest.py (64%) create mode 100644 tests/securitydata/__init__.py create mode 100644 tests/securitydata/conftest.py create mode 100644 tests/securitydata/subcommands/__init__.py create mode 100644 tests/securitydata/subcommands/test_clear_checkpoint.py create mode 100644 tests/securitydata/subcommands/test_print_out.py create mode 100644 tests/securitydata/subcommands/test_send_to.py create mode 100644 tests/securitydata/subcommands/test_write_to.py rename tests/{ => securitydata}/test_cursor_store.py (63%) create mode 100644 tests/securitydata/test_extraction.py create mode 100644 tests/securitydata/test_logger_factory.py create mode 100644 tests/test_main.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..e8a405fe9 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +source = src \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..236bd6311 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# https://editorconfig.org/ + +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 + +[*.py] +max_line_length = 100 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..907b24f47 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,22 @@ +name: build + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python: [2.7, 3.5, 3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python }} + - name: Install tox + run: pip install tox==3.14.3 + - name: Run Tox + run: tox -e py # Run tox using the version of Python in `PATH` diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..b065aeb9f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,69 @@ +name: publish + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build Release + run: | + python setup.py sdist bdist_wheel + - name: Set File Names and Release IDs + run: | + src_file=( ./dist/*.tar.gz ) + wheel_file=( ./dist/*.whl ) + echo "::set-env name=RELEASE_ID::$(jq --raw-output '.release.id' $GITHUB_EVENT_PATH)" + echo "::set-env name=SOURCE_DIST_FILE::$(basename $src_file)" + echo "::set-env name=WHEEL_FILE::$(basename $wheel_file)" + - name: Set Upload Url + run: | + echo "::set-env name=UPLOAD_URL::https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets{?name,label}" + - name: Output Variables For Uploading + id: get_upload_vars + run: | + echo "Release ID: $RELEASE_ID" + echo "Source Dist File: $SOURCE_DIST_FILE" + echo "Source Dist Upload Url: $SOURCE_DIST_URL" + echo "Wheel File: $WHEEL_FILE" + echo "Upload Url: $UPLOAD_URL" + echo "::set-output name=source_dist_path::./dist/${SOURCE_DIST_FILE}" + echo "::set-output name=source_dist_name::${SOURCE_DIST_FILE}" + echo "::set-output name=wheel_path::./dist/${WHEEL_FILE}" + echo "::set-output name=wheel_name::./dist/${WHEEL_FILE}" + echo "::set-output name=upload_url::${UPLOAD_URL}" + - name: Upload Source Distribution to GitHub release + uses: actions/upload-release-asset@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.get_upload_vars.outputs.upload_url }} + asset_path: ${{ steps.get_upload_vars.outputs.source_dist_path }} + asset_name: ${{ steps.get_upload_vars.outputs.source_dist_name }} + asset_content_type: application/x-gzip + - name: Upload Wheel to GitHub Release + uses: actions/upload-release-asset@v1.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.get_upload_vars.outputs.upload_url }} + asset_path: ${{ steps.get_upload_vars.outputs.wheel_path }} + asset_name: ${{ steps.get_upload_vars.outputs.wheel_name }} + asset_content_type: application/zip + - name: Publish Build to PyPI + env: + TWINE_USERNAME: '__token__' + TWINE_PASSWORD: ${{ secrets.PYPI_ACCESS_TOKEN }} + run: | + twine upload dist/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4c964cf03..7b2c0a5bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ # Test config file *config.cfg +.DS_Store + # IDE files .idea/ +.vscode # Database files *.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 1395ae308..f54bc8dba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ # Changelog + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), @@ -10,22 +11,36 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased ### Removed -- Removed config file settings and `-c` CLI arg. Use `c42sec profile set`. -- Removed `--clear-password` CLI argument. Use `c42sec profile set -p`. You will be prompted. + +- Removed config file settings and `-c` CLI arg. Use `code42 profile set`. +- Removed `--clear-password` CLI argument. Use `code42 profile set -p`. You will be prompted. +- Removed top-level destination args. Use subcommands `write-to`. `send-to`, `print` off of `code42 security data`. ### Added -- Added ability to view your profile: `c42sec profile show`. + +- Added ability to view your profile: `code42 profile show`. +- Added `securitydata` subcommands: + - Use `code42 securitydata write-to` to output to a file. + - Use `code42 securitydata send-to` to output to a server. + - Use `code42 securitydata print` to outputs to stdout. + - Use `code42 securitydata clear-cursor` to remove the stored cursor for 'incremental' mode. +- Added support for raw JSON queries via `code42 securitydata [subcommand] --advanced-query [JSON]`. ### Changed -- Renamed `c42aed` to `c42sec`. -- Moved CLI arguments `-s`, `-u`, and `--ignore-ssl-errors` to `c42sec profile set` command. +- Renamed base command `c42aed` to `code42`. +- Moved CLI arguments `-s`, `-u`, and `--ignore-ssl-errors` to `code42 profile set` command. +- Renamed and moved top-level `-r` flag. + - Use `-i` on one of these `securitydata` subcommands `write-to`. `send-to`, `print`. +- Moved search arguments to individual `securitydata` subcommands `write-to`. `send-to`, `print`. ## 0.1.1 - 2019-10-29 ### Fixed + - Issue where IOError message was inaccurate when using the wrong port for server destinations. ### Added + - Error handling for all socket errors. - Error handling for IOError 'connection refused'. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e69de29bb..b7a41e6de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to code42cli + +## Development environment + +Install code42cli and its development dependencies. The `-e` option installs py42 in +["editable mode"](https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs). + +```bash +$ pip install -e .[dev] +``` + +If you are using `zsh`, you may need to escape the brackets. + +We use [black](https://black.readthedocs.io/en/stable/) to automatically format our code. +After installing dependencies, be sure to run: + +```bash +$ pre-commit install +``` + +This will set up a pre-commit hook that will automatically format your code to our desired styles whenever you commit. +It requires python 3.6 to run, so be sure to have a python 3.6 executable of some kind in your PATH when you commit. + +## General + +* Use positional argument specifiers in `str.format()` +* Use syntax and built-in modules that are compatible with both Python 2 and 3. +* Use the `code42cli._internal.compat` module to create abstractions around functionality that differs between 2 and 3. + +## Changes + +Document all notable consumer-affecting changes in CHANGELOG.md per principles and guidelines at +[Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## Tests + +We use [tox](https://tox.readthedocs.io/en/latest/#) to run the +[pytest](https://docs.pytest.org/) test framework on Python 2.7, 3.5, 3.6, and 3.7. + +To run all tests, run this at the root of the repo: + +```bash +$ tox +``` + +If you're using a virtual environment, this will only run the tests within that environment/version of Python. +To run the tests on all supported versions of Python in a local dev environment, we recommend using +[pyenv](https://github.com/pyenv/pyenv) and tox in your system (non-virtual) environment: + +```bash +$ pip install tox +$ pyenv install 2.7.16 +$ pyenv install 3.5.7 +$ pyenv install 3.6.9 +$ pyenv install 3.7.4 +$ pyenv local 2.7.16 3.5.7 3.6.9 3.7.4 +$ tox +``` + +### Writing tests + +Put actual before expected values in assert statements. Pytest assumes this order. + +```python +a = 4 +assert a % 2 == 0 +``` + +Use the following naming convention with test methods: + +test\_\[unit_under_test\]\_\[variables_for_the_test\]\_\[expected_state\] + +Example: + +```python +def test_add_one_and_one_equals_two(): +``` diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..8a3560439 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.md LICENSE.md tox.ini \ No newline at end of file diff --git a/README.md b/README.md index 4971c4dd3..82672e44e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# c42sec +# The Code42 CLI -The c42seceventcli AED module contains a CLI tool for extracting AED events as well as an optional state manager -for recording timestamps. The state manager records timestamps so that on future runs, -you only extract events you did not previously extract. +Use the `code42` command to interact with your Code42 environment. +`code42 securitydata` is a CLI tool for extracting AED events. +Additionally, `code42 securitydata` can record a checkpoint so that you only get events you have not previously gotten. ## Requirements @@ -10,7 +10,7 @@ you only extract events you did not previously extract. - Code42 Server 6.8.x+ ## Installation -Install `c42sec` using: +Install the `code42` CLI using: ```bash $ python setup.py install @@ -18,20 +18,62 @@ $ python setup.py install ## Usage -First, set your profile - +First, set your profile: ```bash -c42sec profile set -s https://example.authority.com -u security.admin@example.com -p +code42 profile set -s https://example.authority.com -u security.admin@example.com ``` +Your profile contains the necessary properties for logging into Code42 servers. +You will prompted for a password if there is not one saved for your current username/authority URL combination. -`-p` will prompt for your password securely. If your username does not have a password stored, you will be prompted anyway. +To explicitly set your password, use `-p`: +```bash +code42 profile set -p +``` +You will be securely prompted to input your password. +Your password is not stored in plain-text, and is not shown when you do `code42 profile show`. +However, `code42 profile show` will confirm that there is a password set for your profile. To ignore SSL errors, do: +```bash +code42 profile set --disable-ssl-errors +``` +To re-enable SSL errors, do: ```bash -c42sec profile set --ignore-ssl-errors true +code42 profile set --enable-ssl-errors ``` +Next, you can query for events and send them to three possible destination types +* stdout +* A file +* A server, such as SysLog + +To print events to stdout, do: +```bash +code42 securitydata print +``` + +To write events to a file, do: +```bash +code42 securitydata write-to filename.txt +``` + +To send events to a server, do: +```bash +code42 securitydata send-to https://syslog.company.com -p TCP +``` + +Each destination-type subcommand shares query parameters +* `-t` (exposure types) +* `-b` (begin date) +* `-e` (end date) +* `--advanced-query` (raw JSON query) + +Note that you cannot use other query parameters if you use `--advanced-query`. + +To learn more about acceptable arguments, add the `-h` flag to `code42` or and of the destination-type subcommands. + + # Known Issues Only the first 10,000 of each set of events containing the exact same insertion timestamp is reported. diff --git a/aed_config.default.cfg b/aed_config.default.cfg deleted file mode 100644 index 2570443a3..000000000 --- a/aed_config.default.cfg +++ /dev/null @@ -1,21 +0,0 @@ -; OPTIONAL CONFIG FILE -; Use this file as an example for which arguments the program accepts. -; Make a copy of this file, edit it, and use with the `-c` flag: -; c42aed -c path/to/config -; Some args may not apply, and you can remove what you do not need. -; You can use this file together with CLI args if you want. - -[Code42] -c42_authority_url=https://example.authority.com -c42_username=user@code42.com -begin_date=2019-01-01 -end_date=2019-02-02 -ignore_ssl_errors=False -output_format=JSON -record_cursor=False -exposure_types=SharedViaLink, SharedToDomain, ApplicationRead, CloudStorage, RemovableMedia, IsPublic -debug_mode=False -destination_type=syslog -destination=https://log.example.com -destination_port=514 -destination_protocol=TCP diff --git a/c42sec/.DS_Store b/c42sec/.DS_Store deleted file mode 100644 index 271b16bf50444d4f5f29c62aa5a97fb05132aff2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ5Iw;5S)b+kjhw&^Wh#?16a}>@#$f1e&2m&H4zidoOLdN>r2rQa}oPE8yRUMtAImV`6+d7-9q< zE|?DEI%WxC^8~RMj)~0BEUCn#T8$W%bmm*t^};bR>986;te$K&p;$bf=eH<_^+ZJ} zAO)rh+~#)e{r`df!~8!bX(t7wz`s(!7Teu+!&j=_I(s?qwT-@~d(9W!jq9K=L^~!% iJLbmQ@m&;UUGp{X_rftT=*$P5sGkAXMJ5IQT7e7eyA}%o diff --git a/c42sec/main.py b/c42sec/main.py deleted file mode 100644 index a08c5451f..000000000 --- a/c42sec/main.py +++ /dev/null @@ -1,33 +0,0 @@ -from argparse import ArgumentParser -from c42sec.profile import profile -from c42sec.send_to import send_to -from c42sec.write_to import write_to - - -def main(): - c42sec_arg_parser = ArgumentParser() - subcommand_parser = c42sec_arg_parser.add_subparsers() - _init_subcommands(subcommand_parser) - args = c42sec_arg_parser.parse_args() - _call(args, c42sec_arg_parser.print_help) - - -def _init_subcommands(subcommand_parser): - profile.init(subcommand_parser) - send_to.init(subcommand_parser) - write_to.init(subcommand_parser) - - -def _call(args, print_help): - """Call provided subcommand with args.""" - try: - args.func(args) - except AttributeError as err: - if str(err) == "'Namespace' object has no attribute 'func'": - print_help() - else: - print(err) - - -if __name__ == "__main__": - main() diff --git a/c42sec/send_to/send_to.py b/c42sec/send_to/send_to.py deleted file mode 100644 index d143d842b..000000000 --- a/c42sec/send_to/send_to.py +++ /dev/null @@ -1,7 +0,0 @@ -def init(subcommand_parser): - send_to_parser = subcommand_parser.add_parser("send-to") - send_to_parser.set_defaults(func=send_to) - - -def send_to(args): - print("Send to called") diff --git a/c42sec/write_to/write_to.py b/c42sec/write_to/write_to.py deleted file mode 100644 index 367e50a14..000000000 --- a/c42sec/write_to/write_to.py +++ /dev/null @@ -1,7 +0,0 @@ -def init(subcommand_parser): - write_to_parser = subcommand_parser.add_parser("write-to") - write_to_parser.set_defaults(func=write_to) - - -def write_to(args): - print("Send to called") diff --git a/setup.py b/setup.py index 416071bf5..34e2507c3 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,51 @@ +from os import path from setuptools import find_packages, setup +from codecs import open + +here = path.abspath(path.dirname(__file__)) + +about = {} +with open(path.join(here, "src", "code42cli", "__version__.py"), encoding="utf8") as fh: + exec(fh.read(), about) + +with open(path.join(here, "README.md"), "r", "utf-8") as f: + readme = f.read() setup( - name="c42sec", - version="0.1.1", - description="CLI for retrieving Code42 Exfiltration Detection events", - packages=find_packages(include=["c42sec", "c42sec.*"]), + name="code42cli", + version=about["__version__"], + description="The office command line tool for interacting with Code42", + long_description=readme, + long_description_content_type="text/markdown", + packages=find_packages("src"), + package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", - install_requires=["c42secevents", "urllib3", "keyring==18.0.1"], + install_requires=["c42eventextractor==0.1.3", "keyring==18.0.1","py42==0.4.4"], license="MIT", include_package_data=True, zip_safe=False, - extras_require={"dev": ["pre-commit==1.18.3", "pytest==4.6.5", "pytest-mock==1.10.4"]}, - entry_points={"console_scripts": ["c42sec=c42sec.main:main"]}, + extras_require={ + "dev": [ + "pre-commit", + "pytest==4.6.5", + "pytest-cov == 2.8.1", + "pytest-mock==2.0.0", + "tox==3.14.3", + ] + }, + classifiers=[ + "Intended Audience :: Developers", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: Implementation :: CPython", + ], + entry_points={"console_scripts": ["code42=code42cli.main:main"]}, ) diff --git a/c42sec/__init__.py b/src/__init__.py similarity index 100% rename from c42sec/__init__.py rename to src/__init__.py diff --git a/src/code42cli/__init__.py b/src/code42cli/__init__.py new file mode 100644 index 000000000..c19bb91f8 --- /dev/null +++ b/src/code42cli/__init__.py @@ -0,0 +1 @@ +from code42cli.main import main diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py new file mode 100644 index 000000000..d3ec452c3 --- /dev/null +++ b/src/code42cli/__version__.py @@ -0,0 +1 @@ +__version__ = "0.2.0" diff --git a/src/code42cli/compat.py b/src/code42cli/compat.py new file mode 100644 index 000000000..3972f6f2c --- /dev/null +++ b/src/code42cli/compat.py @@ -0,0 +1,21 @@ +""" +This module handles import compatibility issues between Python 2 and +Python 3. +""" +# pylint: disable=undefined-variable,import-error,unused-import,no-name-in-module + +import sys + +_ver = sys.version_info + +#: Python 2.x? +is_py2 = _ver[0] == 2 + +if is_py2: + from urlparse import urljoin, urlparse + + str = unicode +else: + from urllib.parse import urljoin, urlparse + + str = str diff --git a/src/code42cli/main.py b/src/code42cli/main.py new file mode 100644 index 000000000..576410fc5 --- /dev/null +++ b/src/code42cli/main.py @@ -0,0 +1,23 @@ +from argparse import ArgumentParser + +from code42cli.profile import profile +import code42cli.securitydata.main as securitydata + + +def main(): + code42_arg_parser = ArgumentParser() + subcommand_parser = code42_arg_parser.add_subparsers() + profile.init(subcommand_parser) + securitydata.init_subcommand(subcommand_parser) + _call_subcommand(code42_arg_parser) + + +def _call_subcommand(parser): + try: + args = parser.parse_args() + args.func(args) + except AttributeError as ex: + if str(ex) == "'Namespace' object has no attribute 'func'": + parser.print_help() + return + raise ex diff --git a/c42sec/profile/__init__.py b/src/code42cli/profile/__init__.py similarity index 100% rename from c42sec/profile/__init__.py rename to src/code42cli/profile/__init__.py diff --git a/c42sec/profile/_config.py b/src/code42cli/profile/config.py similarity index 87% rename from c42sec/profile/_config.py rename to src/code42cli/profile/config.py index a4370fc1d..cbda0f3a6 100644 --- a/c42sec/profile/_config.py +++ b/src/code42cli/profile/config.py @@ -1,7 +1,10 @@ +from __future__ import print_function import os -import c42sec.util as util from configparser import ConfigParser +from code42cli.compat import str +import code42cli.util as util + class ConfigurationKeys(object): USER_SECTION = u"Code42" @@ -18,7 +21,7 @@ def get_config_profile(): util.print_error("Profile is not set.") print("") print("To set, use: ") - util.print_bold("\tc42sec profile set -s -u ") + util.print_bold("\tcode42cli profile set -s -u ") print("") exit(1) @@ -65,7 +68,7 @@ def set_ignore_ssl_errors(new_value): def _get_config_file_path(): path = "{}config.cfg".format(util.get_user_project_path()) - if not os.path.exists(path): + if not os.path.exists(path) or not _verify_config_file(path): _create_new_config_file(path) return path @@ -109,3 +112,11 @@ def _save(parser, key=None, path=None): util.open_file(path, "w+", lambda f: parser.write(f)) if key is not None: print("'{}' has been successfully updated".format(key)) + + +def _verify_config_file(path): + keys = ConfigurationKeys + config_parser = ConfigParser() + config_parser.read(path) + sections = config_parser.sections() + return keys.USER_SECTION in sections and keys.INTERNAL_SECTION in sections diff --git a/c42sec/profile/_password.py b/src/code42cli/profile/password.py similarity index 72% rename from c42sec/profile/_password.py rename to src/code42cli/profile/password.py index db22899a2..e21c7a8ec 100644 --- a/c42sec/profile/_password.py +++ b/src/code42cli/profile/password.py @@ -1,13 +1,16 @@ +from __future__ import print_function import keyring from getpass import getpass -import c42sec.profile._config as config -from c42sec.profile._config import ConfigurationKeys +import code42cli.profile.config as config +from code42cli.profile.config import ConfigurationKeys -_ROOT_SERVICE_NAME = u"c42sec" + +_ROOT_SERVICE_NAME = u"code42cli" def get_password(prompt_if_not_exists=True): + """Gets your currently stored password for your username / authority URL combo.""" profile = config.get_config_profile() service_name = _get_service_name(profile) username = _get_username(profile) @@ -19,6 +22,7 @@ def get_password(prompt_if_not_exists=True): def set_password(): + """Prompts and sets your password for your username / authority URL combo.""" password = getpass() profile = config.get_config_profile() service_name = _get_service_name(profile) diff --git a/c42sec/profile/profile.py b/src/code42cli/profile/profile.py similarity index 75% rename from c42sec/profile/profile.py rename to src/code42cli/profile/profile.py index c4f6ee20f..e7ac8db7c 100644 --- a/c42sec/profile/profile.py +++ b/src/code42cli/profile/profile.py @@ -1,10 +1,12 @@ -import c42sec.profile._config as config -import c42sec.profile._password as password -from c42sec.profile._config import ConfigurationKeys -from c42sec.util import print_error +from __future__ import print_function +import code42cli.profile.config as config +import code42cli.profile.password as password +from code42cli.profile.config import ConfigurationKeys +from code42cli.util import print_error -class C42SecProfile(object): + +class Code42Profile(object): authority_url = "" username = "" ignore_ssl_errors = False @@ -12,29 +14,29 @@ class C42SecProfile(object): def init(subcommand_parser): - """Sets up the `profile` command with `show` and `set` subcommands. + """Sets up the `profile` subcommand with `show` and `set` subcommands. `show` will print the current profile while `set` will modify profile properties. Use `-h` after any subcommand for usage. Args: - subcommand_parser: The subparsers group created by the parent parser + subcommand_parser: The subparsers group created by the parent parser. """ parser_profile = subcommand_parser.add_parser("profile") parser_profile.set_defaults(func=show_profile) profile_subparsers = parser_profile.add_subparsers() - parser_show = profile_subparsers.add_parser("show") - parser_set = profile_subparsers.add_parser("set") + parser_for_show_command = profile_subparsers.add_parser("show") + parser_for_set_command = profile_subparsers.add_parser("set") - parser_show.set_defaults(func=show_profile) - parser_set.set_defaults(func=set_profile) - _add_set_command_args(parser_set) + parser_for_show_command.set_defaults(func=show_profile) + parser_for_set_command.set_defaults(func=set_profile) + _add_args_to_set_command(parser_for_set_command) def get_profile(): - # type: () -> C42SecProfile - """Returns the current profile object""" + # type: () -> Code42Profile + """Returns the current profile object.""" profile_values = config.get_config_profile() - profile = C42SecProfile() + profile = Code42Profile() profile.authority_url = profile_values.get(ConfigurationKeys.AUTHORITY_KEY) profile.username = profile_values.get(ConfigurationKeys.USERNAME_KEY) profile.ignore_ssl_errors = profile_values.get(ConfigurationKeys.IGNORE_SSL_ERRORS_KEY) @@ -71,13 +73,13 @@ def set_profile(args): show_profile() -def _add_set_command_args(parser): - _add_authority_arg(parser) - _add_username_arg(parser) - _add_password_arg(parser) - _add_disable_ssl_errors_arg(parser) - _add_enable_ssl_errors_arg(parser) - _add_show_arg(parser) +def _add_args_to_set_command(parser_for_set_command): + _add_authority_arg(parser_for_set_command) + _add_username_arg(parser_for_set_command) + _add_password_arg(parser_for_set_command) + _add_disable_ssl_errors_arg(parser_for_set_command) + _add_enable_ssl_errors_arg(parser_for_set_command) + _add_show_arg(parser_for_set_command) def _add_authority_arg(parser): @@ -106,7 +108,7 @@ def _add_password_arg(parser): "--password", action="store_true", dest="do_set_c42_password", - help="The password for the Code42 API user. " "Passwords are not stored in plain text.", + help="The password for the Code42 API user. Passwords are not stored in plain text.", ) @@ -126,7 +128,7 @@ def _add_enable_ssl_errors_arg(parser): action="store_true", default=None, dest="enable_ssl_errors", - help="Do not validate the SSL certificates of Code42 servers.", + help="Do validate the SSL certificates of Code42 servers.", ) @@ -162,11 +164,10 @@ def _try_set_ignore_ssl_errors(args): def _try_set_password(args): - # Must happen after setting username if args.do_set_c42_password: password.set_password() - # This will prompt use for password if it does not exist for the current user name. + # Prompt for password if it does not exist for the current username / authority host address combo. password.get_password() @@ -176,12 +177,9 @@ def _verify_args_for_initial_profile_set(args): ): if args.c42_username is None: print_error("Missing username argument.") - if args.c42_authority_url is None: print_error("Missing Code42 Authority URL argument.") - return False - return True diff --git a/c42sec/send_to/__init__.py b/src/code42cli/securitydata/__init__.py similarity index 100% rename from c42sec/send_to/__init__.py rename to src/code42cli/securitydata/__init__.py diff --git a/c42sec/write_to/__init__.py b/src/code42cli/securitydata/arguments/__init__.py similarity index 100% rename from c42sec/write_to/__init__.py rename to src/code42cli/securitydata/arguments/__init__.py diff --git a/src/code42cli/securitydata/arguments/main.py b/src/code42cli/securitydata/arguments/main.py new file mode 100644 index 000000000..fd08af3e0 --- /dev/null +++ b/src/code42cli/securitydata/arguments/main.py @@ -0,0 +1,38 @@ +from code42cli.securitydata.options import OutputFormat + + +IS_INCREMENTAL_KEY = "is_incremental" + + +def add_arguments_to_parser(parser): + _add_output_format_arg(parser) + _add_incremental_arg(parser) + _add_debug_args(parser) + + +def _add_output_format_arg(parser): + parser.add_argument( + "-f", + "--format", + dest="format", + action="store", + choices=OutputFormat(), + default=OutputFormat.JSON, + help="The format used for outputting events.", + ) + + +def _add_incremental_arg(parser): + parser.add_argument( + "-i", + "--incremental", + dest=IS_INCREMENTAL_KEY, + action="store_true", + help="Only get events that were not previously retrieved.", + ) + + +def _add_debug_args(parser): + parser.add_argument( + "-d", "--debug", dest="is_debug_mode", action="store_true", help="Turn on Debug logging." + ) diff --git a/src/code42cli/securitydata/arguments/search.py b/src/code42cli/securitydata/arguments/search.py new file mode 100644 index 000000000..9cfddcffc --- /dev/null +++ b/src/code42cli/securitydata/arguments/search.py @@ -0,0 +1,63 @@ +from code42cli.securitydata.options import ExposureType + + +def add_arguments_to_parser(parser): + _add_advanced_query(parser) + _add_begin_date_arg(parser) + _add_end_date_arg(parser) + _add_exposure_types_arg(parser) + + +class SearchArguments(object): + ADVANCED_QUERY = "advanced_query" + BEGIN_DATE = "begin_date" + END_DATE = "end_date" + EXPOSURE_TYPES = "exposure_types" + + def __iter__(self): + return iter([self.ADVANCED_QUERY, self.BEGIN_DATE, self.END_DATE, self.EXPOSURE_TYPES]) + + +def _add_advanced_query(parser): + parser.add_argument( + "--advanced-query", + action="store", + dest=SearchArguments.ADVANCED_QUERY, + help="A raw JSON file event query. " + "Useful for when the provided query parameters do not satisfy your requirements." + "WARNING: Using advanced queries ignores all other query parameters.", + ) + + +def _add_begin_date_arg(parser): + parser.add_argument( + "-b", + "--begin", + action="store", + dest=SearchArguments.BEGIN_DATE, + help="The beginning of the date range in which to look for events, " + "in YYYY-MM-DD UTC format OR a number (number of minutes ago).", + ) + + +def _add_end_date_arg(parser): + parser.add_argument( + "-e", + "--end", + action="store", + dest=SearchArguments.END_DATE, + help="The end of the date range in which to look for events, " + "in YYYY-MM-DD UTC format OR a number (number of minutes ago).", + ) + + +def _add_exposure_types_arg(parser): + parser.add_argument( + "-t", + "--types", + nargs="+", + action="store", + dest=SearchArguments.EXPOSURE_TYPES, + help="Limits extracted events to those with given exposure types. " + "Available choices={0}".format(list(ExposureType())), + ) diff --git a/c42sec/cursor_store.py b/src/code42cli/securitydata/cursor_store.py similarity index 95% rename from c42sec/cursor_store.py rename to src/code42cli/securitydata/cursor_store.py index 06576d4fb..2944cdcc6 100644 --- a/c42sec/cursor_store.py +++ b/src/code42cli/securitydata/cursor_store.py @@ -1,7 +1,9 @@ +from __future__ import with_statement import sqlite3 -from c42sec.util import get_user_project_path -_INSERTION_TIMESTAMP_FIELD_NAME = u"insertionTimestamp" +from code42cli.util import get_user_project_path + +_INSERTION_TIMESTAMP_FIELD_NAME = "insertionTimestamp" class SecurityEventCursorStore(object): diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py new file mode 100644 index 000000000..b3c8f1abc --- /dev/null +++ b/src/code42cli/securitydata/extraction.py @@ -0,0 +1,132 @@ +from __future__ import print_function +import json +from datetime import datetime, timedelta +from py42.sdk import SDK +from py42 import debug_level +from py42 import settings +from c42eventextractor.common import FileEventHandlers +from c42eventextractor.extractors import AEDEventExtractor +from c42eventextractor.common import convert_datetime_to_timestamp + +from code42cli.securitydata.options import ExposureType +from code42cli.util import print_error +from code42cli.securitydata.cursor_store import AEDCursorStore +from code42cli.securitydata.logger_factory import get_error_logger +from code42cli.profile.profile import get_profile +from code42cli.securitydata.arguments.search import SearchArguments +from code42cli.securitydata.arguments.main import IS_INCREMENTAL_KEY + + +def extract(output_logger, args): + handlers = _create_event_handlers(output_logger, args.is_incremental) + profile = get_profile() + sdk = _get_sdk(profile, args.is_debug_mode) + extractor = AEDEventExtractor(sdk, handlers) + _call_extract(extractor, args) + + +def _create_event_handlers(output_logger, is_incremental): + handlers = FileEventHandlers() + error_logger = get_error_logger() + handlers.handle_error = error_logger.error + if is_incremental: + store = AEDCursorStore() + handlers.record_cursor_position = store.replace_stored_insertion_timestamp + handlers.get_cursor_position = store.get_stored_insertion_timestamp + + def handle_response(response): + response_dict = json.loads(response.text) + events = response_dict.get(u"fileEvents") + for event in events: + output_logger.info(event) + + handlers.handle_response = handle_response + return handlers + + +def _get_sdk(profile, is_debug_mode): + code42 = SDK.create_using_local_account( + profile.authority_url, profile.username, profile.get_password() + ) + if is_debug_mode: + settings.debug_level = debug_level.DEBUG + return code42 + + +def _parse_min_timestamp(begin_date): + min_timestamp = _parse_timestamp(begin_date) + boundary_date = datetime.utcnow() - timedelta(days=90) + boundary = convert_datetime_to_timestamp(boundary_date) + if min_timestamp and min_timestamp < boundary: + print("Argument '--begin' must be within 90 days.") + exit(1) + return min_timestamp + + +def _parse_timestamp(input_string): + try: + # Input represents date str like '2020-02-13' + time = datetime.strptime(input_string, "%Y-%m-%d") + except ValueError: + # Input represents amount of seconds ago like '86400' + if input_string and input_string.isdigit(): + now = datetime.utcnow() + time = now - timedelta(minutes=int(input_string)) + else: + raise ValueError("input must be a positive integer or a date in YYYY-MM-DD format.") + + return convert_datetime_to_timestamp(time) + + +def _call_extract(extractor, args): + if not _determine_if_advanced_query(args): + min_timestamp = _parse_min_timestamp(args.begin_date) if args.begin_date else None + max_timestamp = _parse_timestamp(args.end_date) if args.end_date else None + _verify_exposure_types(args.exposure_types) + _verify_timestamp_order(min_timestamp, max_timestamp) + extractor.extract( + initial_min_timestamp=min_timestamp, + max_timestamp=max_timestamp, + exposure_types=args.exposure_types, + ) + else: + extractor.extract_raw(args.advanced_query) + + +def _determine_if_advanced_query(args): + if args.advanced_query is not None: + given_args = vars(args) + for key in given_args: + val = given_args[key] + if not _verify_compatibility_with_advanced_query(key, val): + print_error("You cannot use --advanced-query with additional search args.") + exit(1) + return True + return False + + +def _verify_compatibility_with_advanced_query(key, val): + if val is not None: + is_other_search_arg = key in SearchArguments() and key != SearchArguments.ADVANCED_QUERY + is_incremental = key == IS_INCREMENTAL_KEY and val + return not is_other_search_arg and not is_incremental + return True + + +def _verify_exposure_types(exposure_types): + if exposure_types is None: + return + options = list(ExposureType()) + for exposure_type in exposure_types: + if exposure_type not in options: + print_error("'{0}' is not a valid exposure type.".format(exposure_type)) + exit(1) + + +def _verify_timestamp_order(begin_timestamp, end_timestamp): + if begin_timestamp is None or end_timestamp is None: + return + + if begin_timestamp >= end_timestamp: + print_error("Begin date cannot be after end date") + exit(1) diff --git a/src/code42cli/securitydata/logger_factory.py b/src/code42cli/securitydata/logger_factory.py new file mode 100644 index 000000000..4e721ab01 --- /dev/null +++ b/src/code42cli/securitydata/logger_factory.py @@ -0,0 +1,76 @@ +import sys +import logging +from logging.handlers import RotatingFileHandler +from c42eventextractor.logging.formatters import ( + AEDDictToJSONFormatter, + AEDDictToCEFFormatter, + AEDDictToRawJSONFormatter, +) +from c42eventextractor.logging.handlers import NoPrioritySysLogHandler + +from code42cli.securitydata.options import OutputFormat +from code42cli.util import get_user_project_path + + +def get_logger_for_stdout(output_format): + logger = logging.getLogger("code42_stdout_{0}".format(output_format.lower())) + if _logger_has_handlers(logger): + return logger + + handler = logging.StreamHandler(sys.stdout) + return _init_logger(logger, handler, output_format) + + +def get_logger_for_file(filename, output_format): + logger = logging.getLogger("code42_file_{0}".format(output_format.lower())) + if _logger_has_handlers(logger): + return logger + + handler = logging.FileHandler(filename, delay=True) + return _init_logger(logger, handler, output_format) + + +def get_logger_for_server(hostname, protocol, output_format): + logger = logging.getLogger("code42_syslog_{0}".format(output_format.lower())) + if _logger_has_handlers(logger): + return logger + + handler = NoPrioritySysLogHandler(hostname, protocol=protocol) + return _init_logger(logger, handler, output_format) + + +def get_error_logger(): + log_path = get_user_project_path("log") + log_path = "{0}/code42_errors.log".format(log_path) + logger = logging.getLogger("code42_error_logger") + if _logger_has_handlers(logger): + return logger + + formatter = logging.Formatter("%(asctime)s %(message)s") + handler = RotatingFileHandler(log_path, maxBytes=250000000) + return _apply_logger_dependencies(logger, handler, formatter) + + +def _logger_has_handlers(logger): + return len(logger.handlers) + + +def _init_logger(logger, handler, output_format): + formatter = _get_formatter(output_format) + logger.setLevel(logging.INFO) + return _apply_logger_dependencies(logger, handler, formatter) + + +def _apply_logger_dependencies(logger, handler, formatter): + handler.setFormatter(formatter) + logger.addHandler(handler) + return logger + + +def _get_formatter(output_format): + if output_format == OutputFormat.JSON: + return AEDDictToJSONFormatter() + elif output_format == OutputFormat.CEF: + return AEDDictToCEFFormatter() + else: + return AEDDictToRawJSONFormatter() diff --git a/src/code42cli/securitydata/main.py b/src/code42cli/securitydata/main.py new file mode 100644 index 000000000..e45e3de2a --- /dev/null +++ b/src/code42cli/securitydata/main.py @@ -0,0 +1,11 @@ +from code42cli.securitydata.subcommands import clear_checkpoint, print_out, write_to +from code42cli.securitydata.subcommands import send_to + + +def init_subcommand(subcommand_parser): + securitydata_arg_parser = subcommand_parser.add_parser("securitydata") + securitydata_subparser = securitydata_arg_parser.add_subparsers() + send_to.init(securitydata_subparser) + write_to.init(securitydata_subparser) + print_out.init(securitydata_subparser) + clear_checkpoint.init(securitydata_subparser) diff --git a/src/code42cli/securitydata/options.py b/src/code42cli/securitydata/options.py new file mode 100644 index 000000000..f7fa8c45b --- /dev/null +++ b/src/code42cli/securitydata/options.py @@ -0,0 +1,40 @@ +class OutputFormat(object): + CEF = "CEF" + JSON = "JSON" + RAW = "RAW-JSON" + + def __iter__(self): + return iter([self.CEF, self.JSON, self.RAW]) + + +class ExposureType(object): + SHARED_VIA_LINK = "SharedViaLink" + SHARED_TO_DOMAIN = "SharedToDomain" + APPLICATION_READ = "ApplicationRead" + CLOUD_STORAGE = "CloudStorage" + REMOVABLE_MEDIA = "RemovableMedia" + IS_PUBLIC = "IsPublic" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [ + self.SHARED_VIA_LINK, + self.SHARED_TO_DOMAIN, + self.APPLICATION_READ, + self.CLOUD_STORAGE, + self.REMOVABLE_MEDIA, + self.IS_PUBLIC, + ] + + +class ServerProtocol(object): + TCP = "TCP" + UDP = "UDP" + + def __iter__(self): + return iter([self.TCP, self.UDP]) diff --git a/src/code42cli/securitydata/subcommands/__init__.py b/src/code42cli/securitydata/subcommands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/code42cli/securitydata/subcommands/clear_checkpoint.py b/src/code42cli/securitydata/subcommands/clear_checkpoint.py new file mode 100644 index 000000000..15e1574db --- /dev/null +++ b/src/code42cli/securitydata/subcommands/clear_checkpoint.py @@ -0,0 +1,22 @@ +from code42cli.securitydata.cursor_store import AEDCursorStore + + +def init(subcommand_parser): + """Sets up the `clear-checkpoint` subcommand for cleared the stored checkpoint for `incremental` mode. + Args: + subcommand_parser: The subparsers group created by the parent parser. + """ + parser = subcommand_parser.add_parser("clear-checkpoint") + parser.set_defaults(func=clear_checkpoint) + + +def clear_checkpoint(*args): + """Removes the stored checkpoint that keeps track of the last event you got. + To use, do `code42cli clear-checkpoint`. + This affects `incremental` mode by causing it to behave like it has never been run before. + """ + AEDCursorStore().reset() + + +if __name__ == "__main__": + clear_checkpoint() diff --git a/src/code42cli/securitydata/subcommands/print_out.py b/src/code42cli/securitydata/subcommands/print_out.py new file mode 100644 index 000000000..f9c8614ec --- /dev/null +++ b/src/code42cli/securitydata/subcommands/print_out.py @@ -0,0 +1,22 @@ +from code42cli.securitydata.logger_factory import get_logger_for_stdout +from code42cli.securitydata.arguments import search as search_args +from code42cli.securitydata.arguments import main as main_args +from code42cli.securitydata.extraction import extract + + +def init(subcommand_parser): + """Sets up the `print` subcommand. + Use `-h` after any subcommand for usage. + Args: + subcommand_parser: The subparsers group created by the parent parser. + """ + parser = subcommand_parser.add_parser("print") + parser.set_defaults(func=print_out) + search_args.add_arguments_to_parser(parser) + main_args.add_arguments_to_parser(parser) + + +def print_out(args): + """Activates 'print' command. It gets security events and prints them to stdout.""" + logger = get_logger_for_stdout(args.format) + extract(logger, args) diff --git a/src/code42cli/securitydata/subcommands/send_to.py b/src/code42cli/securitydata/subcommands/send_to.py new file mode 100644 index 000000000..dd416923a --- /dev/null +++ b/src/code42cli/securitydata/subcommands/send_to.py @@ -0,0 +1,41 @@ +from code42cli.securitydata.logger_factory import get_logger_for_server +from code42cli.securitydata.arguments import main as main_args +from code42cli.securitydata.arguments import search as search_args +from code42cli.securitydata.extraction import extract +from code42cli.securitydata.options import ServerProtocol + + +def init(subcommand_parser): + """Sets up the `send-to` subcommand for sending logs to a server, such as SysLog. + Use `-h` after any subcommand for usage. + Args: + subcommand_parser: The subparsers group created by the parent parser + """ + parser = subcommand_parser.add_parser("send-to") + parser.set_defaults(func=send_to) + _add_server_arg(parser) + _add_protocol_arg(parser) + search_args.add_arguments_to_parser(parser) + main_args.add_arguments_to_parser(parser) + + +def send_to(args): + """Activates 'send-to' command. It gets security events and logs them to the given server.""" + logger = get_logger_for_server(args.server, args.protocol, args.format) + extract(logger, args) + + +def _add_server_arg(parser): + parser.add_argument(action="store", dest="server", help="The server address to send output to.") + + +def _add_protocol_arg(parser): + parser.add_argument( + "-p", + "--protocol", + action="store", + dest="protocol", + choices=ServerProtocol(), + default=ServerProtocol.UDP, + help="Protocol used to send logs to server.", + ) diff --git a/src/code42cli/securitydata/subcommands/write_to.py b/src/code42cli/securitydata/subcommands/write_to.py new file mode 100644 index 000000000..b064163d6 --- /dev/null +++ b/src/code42cli/securitydata/subcommands/write_to.py @@ -0,0 +1,29 @@ +from code42cli.securitydata.logger_factory import get_logger_for_file +from code42cli.securitydata.arguments import main as main_args +from code42cli.securitydata.arguments import search as search_args +from code42cli.securitydata.extraction import extract + + +def init(subcommand_parser): + """Sets up the `write-to` subcommand for writing logs to a file. + Use `-h` after any subcommand for usage. + Args: + subcommand_parser: The subparsers group created by the parent parser. + """ + parser = subcommand_parser.add_parser("write-to") + parser.set_defaults(func=write_to) + _add_filename_subcommand(parser) + search_args.add_arguments_to_parser(parser) + main_args.add_arguments_to_parser(parser) + + +def write_to(args): + """Activates 'write-to' command. It gets security events and writes them to the given file.""" + logger = get_logger_for_file(args.filename, args.format) + extract(logger, args) + + +def _add_filename_subcommand(parser): + parser.add_argument( + action="store", dest="filename", help="The name of the local file to send output to." + ) diff --git a/c42sec/util.py b/src/code42cli/util.py similarity index 73% rename from c42sec/util.py rename to src/code42cli/util.py index 868732496..29df0bbd2 100644 --- a/c42sec/util.py +++ b/src/code42cli/util.py @@ -1,8 +1,10 @@ +from __future__ import print_function, with_statement import sys from os import path, makedirs def get_input(prompt): + """Uses correct input function based on Python version.""" if sys.version_info >= (3, 0): return input(prompt) else: @@ -10,7 +12,7 @@ def get_input(prompt): def get_user_project_path(subdir=""): - """The path on your user dir to /.c42sec/[subdir]""" + """The path on your user dir to /.code42cli/[subdir].""" package_name = __name__.split(".")[0] home = path.expanduser("~") user_project_path = path.join(home, ".{0}".format(package_name), subdir) @@ -22,11 +24,13 @@ def get_user_project_path(subdir=""): def open_file(file_path, mode, action): + """Wrapper for opening files, useful for testing purposes.""" with open(file_path, mode) as f: action(f) def print_error(error_text): + """Prints red text.""" print("\033[91mERROR: {}\033[0m".format(error_text)) diff --git a/tests/profile/conftest.py b/tests/conftest.py similarity index 64% rename from tests/profile/conftest.py rename to tests/conftest.py index 57a8268b8..cefa853eb 100644 --- a/tests/profile/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,12 @@ import pytest -from c42sec.profile._config import ConfigurationKeys +from argparse import Namespace + +from code42cli.profile.config import ConfigurationKeys @pytest.fixture def config_profile(mocker): - mock_config = mocker.patch("c42sec.profile._config.get_config_profile") + mock_config = mocker.patch("code42cli.profile.config.get_config_profile") mock_config.return_value = { ConfigurationKeys.USERNAME_KEY: "test.username", ConfigurationKeys.AUTHORITY_KEY: "https://authority.example.com", @@ -21,13 +23,27 @@ def config_parser(mocker): mocks.item_getter = mocker.patch("configparser.ConfigParser.__getitem__") mocks.section_adder = mocker.patch("configparser.ConfigParser.add_section") mocks.reader = mocker.patch("configparser.ConfigParser.read") + mocks.sections = mocker.patch("configparser.ConfigParser.sections") mocks.initializer.return_value = None return mocks +@pytest.fixture +def namespace(mocker): + mock = mocker.MagicMock(spec=Namespace) + mock.is_incremental = None + mock.advanced_query = None + mock.is_debug_mode = None + mock.begin_date = None + mock.end_date = None + mock.exposure_types = None + return mock + + class ConfigParserMocks(object): initializer = None item_setter = None item_getter = None section_adder = None reader = None + sections = None diff --git a/tests/profile/test_config.py b/tests/profile/test_config.py index c40c16e5f..9c81c9a89 100644 --- a/tests/profile/test_config.py +++ b/tests/profile/test_config.py @@ -1,59 +1,59 @@ +from __future__ import with_statement import pytest -import c42sec.profile._config as config +import code42cli.profile.config as config -@pytest.fixture -def path_exists_function(mocker): - return mocker.patch("os.path.exists") +class SharedConfigMocks(object): + mocker = None + open_function = None + path_exists_function = None + get_project_path_function = None + config_parser = None + def setup_existing_config_file(self): + self.path_exists_function.return_value = True -@pytest.fixture -def path_exists(path_exists_function): - path_exists_function.return_value = True - return path_exists_function + def setup_non_existing_config_file(self): + self.path_exists_function.return_value = False + def setup_existing_profile(self): + self.config_parser.item_getter.return_value = self._create_config_profile(is_set=True) -@pytest.fixture -def path_does_not_exist(path_exists_function): - path_exists_function.return_value = False - return path_exists_function + def setup_non_existing_profile(self): + self.config_parser.item_getter.return_value = self._create_config_profile(is_set=False) - -@pytest.fixture -def non_existent_profile(mocker, config_parser): - config_parser.item_getter.return_value = create_config_profile(mocker, False) - return config_parser + def _create_config_profile(self, is_set): + config_profile = self.mocker.MagicMock() + config_profile.getboolean.return_value = is_set + bool_getter = self.mocker.MagicMock() + bool_getter.return_value = is_set + config_profile.getboolean = bool_getter + config_profile.__setitem__ = self.mocker.MagicMock() + return config_profile @pytest.fixture -def existent_profile(mocker, config_parser): - config_parser.item_getter.return_value = create_config_profile(mocker, True) - return config_parser - +def shared_config_mocks(mocker, config_parser): + # Project path + get_project_path_function = mocker.patch("code42cli.util.get_user_project_path") + get_project_path_function.return_value = "some/path/" -@pytest.fixture -def mock_project_path(mocker): - project_path_getter = mocker.patch("c42sec.util.get_user_project_path") - project_path_getter.return_value = "some/path/" - return project_path_getter - - -@pytest.fixture -def open_file_function(mocker): - open_file = mocker.patch("c42sec.util.open_file") + # Opening files + open_file_function = mocker.patch("code42cli.util.open_file") new_file = mocker.MagicMock() - open_file.return_value = new_file - return open_file + open_file_function.return_value = new_file + # Path exists + path_exists_function = mocker.patch("os.path.exists") -def create_config_profile(mocker, is_set): - config_profile = mocker.MagicMock() - bool_getter = mocker.MagicMock() - bool_getter.return_value = is_set - config_profile.getboolean = bool_getter - config_profile.__setitem__ = mocker.MagicMock() - return config_profile + mocks = SharedConfigMocks() + mocks.mocker = mocker + mocks.open_function = open_file_function + mocks.path_exists_function = path_exists_function + mocks.get_project_path_function = get_project_path_function + mocks.config_parser = config_parser + return mocks def assert_save_was_called(open_file_function): @@ -61,64 +61,60 @@ def assert_save_was_called(open_file_function): assert call_args[0][0] == "some/path/config.cfg" and call_args[0][1] == "w+" -def test_get_config_profile_when_file_exists_but_profile_does_not_exist_exits( - path_exists, non_existent_profile, mock_project_path -): +def test_get_config_profile_when_file_exists_but_profile_does_not_exist_exits(shared_config_mocks): + shared_config_mocks.setup_existing_config_file() + shared_config_mocks.setup_non_existing_profile() + # It is expected to exit because the user must set their profile before they can see it. with pytest.raises(SystemExit): config.get_config_profile() -def test_get_config_profile_when_file_exists_and_profile_is_set_does_not_exit( - path_exists, existent_profile, mock_project_path -): +def test_get_config_profile_when_file_exists_and_profile_is_set_does_not_exit(shared_config_mocks): + shared_config_mocks.setup_existing_config_file() + shared_config_mocks.setup_existing_profile() + # Presumably, it shows the profile instead of exiting. assert config.get_config_profile() -def test_get_config_profile_when_path_does_not_exist_saves_changes( - path_does_not_exist, open_file_function, mock_project_path, non_existent_profile -): +def test_get_config_profile_when_file_does_not_exist_saves_changes(shared_config_mocks): + shared_config_mocks.setup_non_existing_config_file() + shared_config_mocks.setup_non_existing_profile() + with pytest.raises(SystemExit): config.get_config_profile() # It saves because it is writing default values to the config file - assert_save_was_called(open_file_function) + assert_save_was_called(shared_config_mocks.open_function) -def test_mark_as_set_saves_changes(existent_profile, open_file_function, mock_project_path): +def test_mark_as_set_saves_changes(shared_config_mocks): + shared_config_mocks.setup_existing_profile() config.mark_as_set() - assert_save_was_called(open_file_function) + assert_save_was_called(shared_config_mocks.open_function) -def test_profile_has_been_set_when_when_getboolean_returns_true_returns_true( - existent_profile, open_file_function -): +def test_profile_has_been_set_when_is_set_returns_true(shared_config_mocks): + shared_config_mocks.setup_existing_profile() assert config.profile_has_been_set() -def test_profile_has_been_set_when_when_getboolean_returns_false_returns_false( - non_existent_profile, open_file_function -): +def test_profile_has_been_set_when_is_not_set_returns_false(shared_config_mocks): + shared_config_mocks.setup_non_existing_profile() assert not config.profile_has_been_set() -def test_set_username_saves( - path_does_not_exist, open_file_function, mock_project_path, existent_profile -): +def test_set_username_saves(shared_config_mocks): config.set_username("New user") - assert_save_was_called(open_file_function) + assert_save_was_called(shared_config_mocks.open_function) -def test_set_authority_url_saves( - path_does_not_exist, open_file_function, mock_project_path, existent_profile -): - config.set_authority_url("new url") - assert_save_was_called(open_file_function) +def test_set_authority_url_saves(shared_config_mocks): + config.set_authority_url("New url") + assert_save_was_called(shared_config_mocks.open_function) -def test_set_ignore_ssl_errors_saves( - path_does_not_exist, open_file_function, mock_project_path, existent_profile -): +def test_set_ignore_ssl_errors_saves(shared_config_mocks): config.set_ignore_ssl_errors(True) - assert_save_was_called(open_file_function) + assert_save_was_called(shared_config_mocks.open_function) diff --git a/tests/profile/test_password.py b/tests/profile/test_password.py index e42e50179..afb158566 100644 --- a/tests/profile/test_password.py +++ b/tests/profile/test_password.py @@ -1,5 +1,6 @@ import pytest -import c42sec.profile._password as password + +import code42cli.profile.password as password @pytest.fixture @@ -7,14 +8,14 @@ def keyring_password_getter(mocker): return mocker.patch("keyring.get_password") -@pytest.fixture +@pytest.fixture(autouse=True) def keyring_password_setter(mocker): return mocker.patch("keyring.set_password") @pytest.fixture def getpass_function(mocker): - return mocker.patch("c42sec.profile._password.getpass") + return mocker.patch("code42cli.profile.password.getpass") def test_get_password_uses_expected_service_name_and_username( @@ -22,7 +23,7 @@ def test_get_password_uses_expected_service_name_and_username( ): password.get_password() # See conftest.config_profile - expected_service_name = "c42sec::https://authority.example.com" + expected_service_name = "code42cli::https://authority.example.com" expected_username = "test.username" keyring_password_getter.assert_called_once_with(expected_service_name, expected_username) @@ -36,14 +37,14 @@ def test_get_password_when_password_is_none_returns_password_from_getpass( def test_get_password_when_password_is_not_none_returns_password( - keyring_password_getter, config_profile, getpass_function + keyring_password_getter, config_profile, keyring_password_setter ): keyring_password_getter.return_value = "already stored password 123" assert password.get_password() == "already stored password 123" def test_get_password_when_password_is_none_and_told_to_not_prompt_if_not_exists_returns_none( - keyring_password_getter, config_profile, getpass_function + keyring_password_getter, config_profile ): keyring_password_getter.return_value = None assert password.get_password(prompt_if_not_exists=False) is None @@ -55,7 +56,7 @@ def test_set_password_uses_expected_service_name_username_and_password( getpass_function.return_value = "test password" password.set_password() # See conftest.config_profile - expected_service_name = "c42sec::https://authority.example.com" + expected_service_name = "code42cli::https://authority.example.com" expected_username = "test.username" keyring_password_setter.assert_called_once_with( expected_service_name, expected_username, "test password" diff --git a/tests/profile/test_profile.py b/tests/profile/test_profile.py index 33f2f3489..dc17fa1be 100644 --- a/tests/profile/test_profile.py +++ b/tests/profile/test_profile.py @@ -1,48 +1,48 @@ import pytest from argparse import ArgumentParser -from c42sec.profile import profile +from code42cli.profile import profile @pytest.fixture def username_setter(mocker): - return mocker.patch("c42sec.profile._config.set_username") + return mocker.patch("code42cli.profile.config.set_username") @pytest.fixture def mark_as_set_function(mocker): - return mocker.patch("c42sec.profile._config.mark_as_set") + return mocker.patch("code42cli.profile.config.mark_as_set") @pytest.fixture def authority_url_setter(mocker): - return mocker.patch("c42sec.profile._config.set_authority_url") + return mocker.patch("code42cli.profile.config.set_authority_url") @pytest.fixture def ignore_ssl_errors_setter(mocker): - return mocker.patch("c42sec.profile._config.set_ignore_ssl_errors") + return mocker.patch("code42cli.profile.config.set_ignore_ssl_errors") @pytest.fixture def password_setter(mocker): - return mocker.patch("c42sec.profile._password.set_password") + return mocker.patch("code42cli.profile.password.set_password") @pytest.fixture def password_getter(mocker): - return mocker.patch("c42sec.profile._password.get_password") + return mocker.patch("code42cli.profile.password.get_password") @pytest.fixture def profile_not_set_state(mocker): - profile_verifier = mocker.patch("c42sec.profile._config.profile_has_been_set") + profile_verifier = mocker.patch("code42cli.profile.config.profile_has_been_set") profile_verifier.return_value = False return profile_verifier @pytest.fixture def profile_is_set_state(mocker): - profile_verifier = mocker.patch("c42sec.profile._config.profile_has_been_set") + profile_verifier = mocker.patch("code42cli.profile.config.profile_has_been_set") profile_verifier.return_value = True return profile_verifier @@ -64,17 +64,15 @@ def test_init_adds_parser_that_can_parse_set_command(config_parser): subcommand_parser = ArgumentParser().add_subparsers() profile.init(subcommand_parser) profile_parser = subcommand_parser.choices.get("profile") - - # Commands that require a value will fail here if not provided - assert profile_parser.parse_args( + profile_parser.parse_args( ["set", "-s", "server-arg", "-p", "-u", "username-arg", "--enable-ssl-errors"] ) -def test_get_profile_returns_object_from_config_file(config_parser, config_profile): +def test_get_profile_returns_object_from_config_profile(config_parser, config_profile): user = profile.get_profile() - # Values from config_file fixture + # Values from config_profile fixture assert ( user.username == "test.username" and user.authority_url == "https://authority.example.com" diff --git a/tests/securitydata/__init__.py b/tests/securitydata/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/securitydata/conftest.py b/tests/securitydata/conftest.py new file mode 100644 index 000000000..6f0ccdfa2 --- /dev/null +++ b/tests/securitydata/conftest.py @@ -0,0 +1,2 @@ +ROOT_PATH = "code42cli.securitydata" +SUBCOMMANDS_PATH = "{0}.subcommands".format(ROOT_PATH) diff --git a/tests/securitydata/subcommands/__init__.py b/tests/securitydata/subcommands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/securitydata/subcommands/test_clear_checkpoint.py b/tests/securitydata/subcommands/test_clear_checkpoint.py new file mode 100644 index 000000000..0897af279 --- /dev/null +++ b/tests/securitydata/subcommands/test_clear_checkpoint.py @@ -0,0 +1,22 @@ +import pytest + +from tests.securitydata.conftest import ROOT_PATH +from code42cli.securitydata.subcommands import clear_checkpoint as clearer + + +_CURSOR_STORE_PATH = "{0}.cursor_store".format(ROOT_PATH) + + +@pytest.fixture +def cursor_store(mocker): + mock_init = mocker.patch("{0}.AEDCursorStore.__init__".format(_CURSOR_STORE_PATH)) + mock_init.return_value = None + mock = mocker.MagicMock() + mock_new = mocker.patch("{0}.AEDCursorStore.__new__".format(_CURSOR_STORE_PATH)) + mock_new.return_value = mock + return mock + + +def test_clear_checkpoint_calls_cursor_store_reset(cursor_store): + clearer.clear_checkpoint() + assert cursor_store.reset.call_count == 1 diff --git a/tests/securitydata/subcommands/test_print_out.py b/tests/securitydata/subcommands/test_print_out.py new file mode 100644 index 000000000..5d5abcce5 --- /dev/null +++ b/tests/securitydata/subcommands/test_print_out.py @@ -0,0 +1,57 @@ +import pytest +from argparse import ArgumentParser + +from tests.securitydata.conftest import SUBCOMMANDS_PATH +import code42cli.securitydata.subcommands.print_out as printer + + +_PRINT_PATH = "{0}.print_out".format(SUBCOMMANDS_PATH) + + +@pytest.fixture +def logger_factory(mocker): + return mocker.patch("{0}.get_logger_for_stdout".format(_PRINT_PATH)) + + +@pytest.fixture +def extractor(mocker): + return mocker.patch("{0}.extract".format(_PRINT_PATH)) + + +def test_init_adds_parser_that_can_parse_supported_args(config_parser): + subcommand_parser = ArgumentParser().add_subparsers() + printer.init(subcommand_parser) + print_parser = subcommand_parser.choices.get("print") + print_parser.parse_args( + [ + "-t", + "SharedToDomain", + "ApplicationRead", + "CloudStorage", + "RemovableMedia", + "IsPublic", + "-f", + "JSON", + "-d", + "-b", + "600", + "-e", + "2020-02-02", + ] + ) + + +def test_print_out_uses_logger_for_stdout(namespace, logger_factory, extractor): + namespace.format = "CEF" + printer.print_out(namespace) + logger_factory.assert_called_once_with("CEF") + + +def test_print_out_calls_extract_with_expected_arguments( + mocker, namespace, logger_factory, extractor +): + namespace.format = "CEF" + logger = mocker.MagicMock() + logger_factory.return_value = logger + printer.print_out(namespace) + extractor.assert_called_once_with(logger, namespace) diff --git a/tests/securitydata/subcommands/test_send_to.py b/tests/securitydata/subcommands/test_send_to.py new file mode 100644 index 000000000..6e5b5bc9f --- /dev/null +++ b/tests/securitydata/subcommands/test_send_to.py @@ -0,0 +1,88 @@ +import pytest +from argparse import ArgumentParser + +from tests.securitydata.conftest import SUBCOMMANDS_PATH +from code42cli.securitydata.subcommands import send_to as sender + + +_SEND_PATH = "{0}.send_to".format(SUBCOMMANDS_PATH) + + +@pytest.fixture +def server_namespace(namespace): + namespace.server = "https://www.syslog.example.com" + namespace.protocol = "TCP" + namespace.format = "CEF" + return namespace + + +@pytest.fixture +def logger_factory(mocker): + return mocker.patch("{0}.get_logger_for_server".format(_SEND_PATH)) + + +@pytest.fixture +def extractor(mocker): + return mocker.patch("{0}.extract".format(_SEND_PATH)) + + +def test_init_adds_parser_that_can_parse_supported_args(config_parser): + subcommand_parser = ArgumentParser().add_subparsers() + sender.init(subcommand_parser) + send_parser = subcommand_parser.choices.get("send-to") + send_parser.parse_args( + [ + "https://www.syslog.com", + "-t", + "SharedToDomain", + "ApplicationRead", + "CloudStorage", + "RemovableMedia", + "IsPublic", + "-f", + "JSON", + "-d", + "-b", + "600", + "-e", + "2020-02-02", + ] + ) + + +def test_init_adds_parser_when_not_given_server_causes_system_exit(config_parser): + subcommand_parser = ArgumentParser().add_subparsers() + sender.init(subcommand_parser) + send_parser = subcommand_parser.choices.get("send-to") + with pytest.raises(SystemExit): + send_parser.parse_args( + [ + "-t", + "SharedToDomain", + "ApplicationRead", + "CloudStorage", + "RemovableMedia", + "IsPublic", + "-f", + "JSON", + "-d", + "-b", + "600", + "-e", + "2020-02-02", + ] + ) + + +def test_send_to_uses_logger_for_server(server_namespace, logger_factory, extractor): + sender.send_to(server_namespace) + logger_factory.assert_called_once_with("https://www.syslog.example.com", "TCP", "CEF") + + +def test_send_to_calls_extract_with_expected_arguments( + mocker, server_namespace, logger_factory, extractor +): + logger = mocker.MagicMock() + logger_factory.return_value = logger + sender.send_to(server_namespace) + extractor.assert_called_once_with(logger, server_namespace) diff --git a/tests/securitydata/subcommands/test_write_to.py b/tests/securitydata/subcommands/test_write_to.py new file mode 100644 index 000000000..2ae0e0555 --- /dev/null +++ b/tests/securitydata/subcommands/test_write_to.py @@ -0,0 +1,87 @@ +import pytest +from argparse import ArgumentParser + +from tests.securitydata.conftest import SUBCOMMANDS_PATH +from code42cli.securitydata.subcommands import write_to as writer + + +_WRITE_PATH = "{0}.write_to".format(SUBCOMMANDS_PATH) + + +@pytest.fixture +def file_namespace(namespace): + namespace.filename = "out.txt" + namespace.format = "CEF" + return namespace + + +@pytest.fixture +def logger_factory(mocker): + return mocker.patch("{0}.get_logger_for_file".format(_WRITE_PATH)) + + +@pytest.fixture +def extractor(mocker): + return mocker.patch("{0}.extract".format(_WRITE_PATH)) + + +def test_init_adds_parser_that_can_parse_supported_args(config_parser): + subcommand_parser = ArgumentParser().add_subparsers() + writer.init(subcommand_parser) + write_parser = subcommand_parser.choices.get("write-to") + write_parser.parse_args( + [ + "out.txt", + "-t", + "SharedToDomain", + "ApplicationRead", + "CloudStorage", + "RemovableMedia", + "IsPublic", + "-f", + "JSON", + "-d", + "-b", + "600", + "-e", + "2020-02-02", + ] + ) + + +def test_init_adds_parser_when_not_given_filename_causes_system_exit(config_parser): + subcommand_parser = ArgumentParser().add_subparsers() + writer.init(subcommand_parser) + write_parser = subcommand_parser.choices.get("write-to") + with pytest.raises(SystemExit): + write_parser.parse_args( + [ + "-t", + "SharedToDomain", + "ApplicationRead", + "CloudStorage", + "RemovableMedia", + "IsPublic", + "-f", + "JSON", + "-d", + "-b", + "600", + "-e", + "2020-02-02", + ] + ) + + +def test_write_to_uses_logger_for_file(file_namespace, logger_factory, extractor): + writer.write_to(file_namespace) + logger_factory.assert_called_once_with("out.txt", "CEF") + + +def test_write_to_calls_extract_with_expected_arguments( + mocker, file_namespace, logger_factory, extractor +): + logger = mocker.MagicMock() + logger_factory.return_value = logger + writer.write_to(file_namespace) + extractor.assert_called_once_with(logger, file_namespace) diff --git a/tests/test_cursor_store.py b/tests/securitydata/test_cursor_store.py similarity index 63% rename from tests/test_cursor_store.py rename to tests/securitydata/test_cursor_store.py index 7434af8b9..331f898fb 100644 --- a/tests/test_cursor_store.py +++ b/tests/securitydata/test_cursor_store.py @@ -1,29 +1,25 @@ -from os import path - import pytest -from c42sec.cursor_store import SecurityEventCursorStore - -MOCK_TEST_DB_PATH = "test_path.db" - +from os import path -@pytest.fixture -def sqlite_connection(mocker): - return mocker.patch("sqlite3.connect") +from code42cli.securitydata.cursor_store import SecurityEventCursorStore class TestSecurityEventCursorStore(object): + @pytest.fixture + def sqlite_connection(self, mocker): + return mocker.patch("sqlite3.connect") + def test_init_cursor_store_when_not_given_db_file_path_uses_expected_path_with_db_table_name_as_db_file_name( self, sqlite_connection ): home_dir = path.expanduser("~") - expected_path = path.join(home_dir, ".c42sec/db") + expected_path = path.join(home_dir, ".code42cli/db") expected_db_name = "TEST" expected_db_file_path = "{0}/{1}.db".format(expected_path, expected_db_name) SecurityEventCursorStore(expected_db_name) sqlite_connection.assert_called_once_with(expected_db_file_path) - def test_init_cursor_store_when_given_db_file_path_uses_given_path(self, mocker): - mock_connect_function = mocker.patch("sqlite3.connect") + def test_init_cursor_store_when_given_db_file_path_uses_given_path(self, sqlite_connection): expected_db_file_path = "Hey, look, I'm a file path..." SecurityEventCursorStore("test", expected_db_file_path) - mock_connect_function.assert_called_once_with(expected_db_file_path) + sqlite_connection.assert_called_once_with(expected_db_file_path) diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py new file mode 100644 index 000000000..a04177565 --- /dev/null +++ b/tests/securitydata/test_extraction.py @@ -0,0 +1,213 @@ +import pytest +from datetime import datetime, timedelta + +from code42cli.securitydata.options import ExposureType +from .conftest import ROOT_PATH +from code42cli.securitydata.extraction import extract + + +@pytest.fixture(autouse=True) +def mock_42(mocker): + mock = mocker.patch("py42.sdk.SDK.create_using_local_account") + return mock + + +@pytest.fixture +def logger(mocker): + mock = mocker.MagicMock() + mock.info = mocker.MagicMock() + return mock + + +@pytest.fixture(autouse=True) +def error_logger(mocker): + return mocker.patch("{0}.logger_factory".format(ROOT_PATH)) + + +@pytest.fixture +def cursor_store(mocker): + mock = mocker.patch("c42eventextractor.extractors.AEDCursorStore.__init__") + mock.return_value = None + return mock + + +@pytest.fixture(autouse=True) +def extractor(mocker): + mock = mocker.MagicMock() + mock.extract_raw = mocker.patch("c42eventextractor.extractors.AEDEventExtractor.extract_raw") + mock.extract = mocker.patch("c42eventextractor.extractors.AEDEventExtractor.extract") + return mock + + +@pytest.fixture(autouse=True) +def profile(mocker): + mocker.patch("code42cli.securitydata.extraction.get_profile") + + +def get_test_date_str(days_ago): + now = datetime.utcnow() + days_ago_date = now - timedelta(days=days_ago) + return days_ago_date.strftime("%Y-%m-%d") + + +def get_timestamp_from_date_str(date_str): + date = datetime.strptime(date_str, "%Y-%m-%d") + return (date - datetime.utcfromtimestamp(0)).total_seconds() + + +def get_timestamp_from_seconds_ago(seconds_ago): + date = datetime.utcnow() - timedelta(seconds=seconds_ago) + return (date - datetime.utcfromtimestamp(0)).total_seconds() + + +def test_extract_when_is_advanced_query_uses_only_the_extract_raw_method( + logger, namespace, extractor +): + namespace.advanced_query = "some complex json" + extract(logger, namespace) + extractor.extract_raw.assert_called_once_with("some complex json") + assert extractor.extract.call_count == 0 + + +def test_extract_when_is_advanced_query_and_has_begin_date_exits(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.begin_date = "begin date" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_end_date_exits(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.end_date = "end date" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_exposure_types_exits(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.exposure_types = [ExposureType.SHARED_TO_DOMAIN] + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_incremental_mode_exits(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.is_incremental = True + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( + logger, namespace +): + namespace.advanced_query = "some complex json" + namespace.is_incremental = False + extract(logger, namespace) + + +def test_extract_when_is_not_advanced_query_uses_only_extract_method(logger, extractor, namespace): + extract(logger, namespace) + assert extractor.extract.call_count == 1 + assert extractor.extract_raw.call_count == 0 + + +def test_extract_passed_through_given_exposure_types(logger, error_logger, namespace, extractor): + namespace.exposure_types = [ + ExposureType.IS_PUBLIC, + ExposureType.CLOUD_STORAGE, + ExposureType.APPLICATION_READ, + ] + extract(logger, namespace) + assert extractor.extract.call_args[1]["exposure_types"] == [ + ExposureType.IS_PUBLIC, + ExposureType.CLOUD_STORAGE, + ExposureType.APPLICATION_READ, + ] + + +def test_extract_when_given_begin_date_uses_expected_begin_timestamp( + logger, error_logger, namespace, extractor +): + test_begin_date_str = get_test_date_str(days_ago=89) + namespace.begin_date = test_begin_date_str + extract(logger, namespace) + expected_begin_timestamp = get_timestamp_from_date_str(test_begin_date_str) + actual_begin_timestamp = extractor.extract.call_args[1]["initial_min_timestamp"] + assert actual_begin_timestamp == expected_begin_timestamp + + +def test_extract_when_given_begin_date_as_seconds_ago_uses_expected_begin_timestamp( + logger, error_logger, namespace, extractor +): + namespace.begin_date = "600" + extract(logger, namespace) + expected_timestamp = get_timestamp_from_seconds_ago(600) + actual_timestamp = extractor.extract.call_args[1]["initial_min_timestamp"] + assert pytest.approx(expected_timestamp, actual_timestamp) + + +def test_extract_when_given_end_date_uses_expected_begin_timestamp( + logger, error_logger, namespace, extractor +): + test_end_date_str = get_test_date_str(days_ago=10) + namespace.end_date = test_end_date_str + extract(logger, namespace) + expected_end_timestamp = get_timestamp_from_date_str(test_end_date_str) + actual_end_timestamp = extractor.extract.call_args[1]["max_timestamp"] + assert actual_end_timestamp == expected_end_timestamp + + +def test_extract_when_given_end_date_as_seconds_ago_uses_expected_begin_timestamp( + logger, error_logger, namespace, extractor +): + namespace.end_date = "600" + extract(logger, namespace) + expected_timestamp = get_timestamp_from_seconds_ago(600) + actual_timestamp = extractor.extract.call_args[1]["max_timestamp"] + assert pytest.approx(expected_timestamp, actual_timestamp) + + +def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( + logger, error_logger, namespace, extractor +): + test_begin_date_str = get_test_date_str(days_ago=89) + namespace.begin_date = test_begin_date_str + namespace.end_date = "600" + extract(logger, namespace) + + expected_begin_timestamp = get_timestamp_from_date_str(test_begin_date_str) + expected_end_timestamp = get_timestamp_from_seconds_ago(600) + actual_begin_timestamp = extractor.extract.call_args[1]["initial_min_timestamp"] + actual_end_timestamp = extractor.extract.call_args[1]["max_timestamp"] + + assert actual_begin_timestamp == expected_begin_timestamp + assert pytest.approx(expected_end_timestamp, actual_end_timestamp) + + +def test_extract_when_given_min_timestamp_more_than_ninety_days_back_causes_exit( + logger, error_logger, namespace, extractor +): + namespace.begin_date = get_test_date_str(days_ago=91) + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_end_date_is_before_begin_date_causes_exit( + logger, error_logger, namespace, extractor +): + namespace.begin_date = get_test_date_str(days_ago=5) + namespace.end_date = get_test_date_str(days_ago=6) + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_given_invalid_exposure_type_causes_exit( + logger, error_logger, namespace, extractor +): + namespace.exposure_types = [ + ExposureType.APPLICATION_READ, + "SomethingElseThatIsNotSupported", + ExposureType.IS_PUBLIC, + ] + with pytest.raises(SystemExit): + extract(logger, namespace) diff --git a/tests/securitydata/test_logger_factory.py b/tests/securitydata/test_logger_factory.py new file mode 100644 index 000000000..ccbb4899e --- /dev/null +++ b/tests/securitydata/test_logger_factory.py @@ -0,0 +1,155 @@ +import pytest +import logging +from logging.handlers import RotatingFileHandler +from c42eventextractor.logging.formatters import ( + AEDDictToCEFFormatter, + AEDDictToJSONFormatter, + AEDDictToRawJSONFormatter, +) + +import code42cli.securitydata.logger_factory as factory + + +_SYSLOG_HANDLER_PATH = "c42eventextractor.logging.handlers.NoPrioritySysLogHandler" + + +@pytest.fixture +def no_priority_syslog_handler(mocker): + mock_no_priority_syslog_handler = mocker.MagicMock() + mock_new = mocker.patch("{0}.__new__".format(_SYSLOG_HANDLER_PATH)) + + def set_mock(_, hostname, protocol): + mock_no_priority_syslog_handler.hostname.return_value = hostname + mock_no_priority_syslog_handler.protocol.return_value = protocol + return mock_no_priority_syslog_handler + + mock_new.side_effect = set_mock + mock_new.return_value = mock_no_priority_syslog_handler + return mock_no_priority_syslog_handler + + +def test_get_logger_for_stdout_has_info_level(): + logger = factory.get_logger_for_stdout("CEF") + assert logger.level == logging.INFO + + +def test_get_logger_for_stdout_when_given_cef_format_uses_cef_formatter(): + logger = factory.get_logger_for_stdout("CEF") + assert type(logger.handlers[0].formatter) == AEDDictToCEFFormatter + + +def test_get_logger_for_stdout_when_given_json_format_uses_json_formatter(): + logger = factory.get_logger_for_stdout("JSON") + assert type(logger.handlers[0].formatter) == AEDDictToJSONFormatter + + +def test_get_logger_for_stdout_when_given_raw_json_format_uses_raw_json_formatter(): + logger = factory.get_logger_for_stdout("RAW-JSON") + assert type(logger.handlers[0].formatter) == AEDDictToRawJSONFormatter + + +def test_get_logger_for_stdout_when_called_twice_has_only_one_handler(): + _ = factory.get_logger_for_stdout("CEF") + logger = factory.get_logger_for_stdout("CEF") + assert len(logger.handlers) == 1 + + +def test_get_logger_for_stdout_uses_stream_handler(): + logger = factory.get_logger_for_stdout("CEF") + assert type(logger.handlers[0]) == logging.StreamHandler + + +def test_get_logger_for_file_has_info_level(): + logger = factory.get_logger_for_file("Test.out", "CEF") + assert logger.level == logging.INFO + + +def test_get_logger_for_file_when_given_cef_format_uses_cef_formatter(): + logger = factory.get_logger_for_file("Test.out", "CEF") + assert type(logger.handlers[0].formatter) == AEDDictToCEFFormatter + + +def test_get_logger_for_file_when_given_json_format_uses_json_formatter(): + logger = factory.get_logger_for_file("Test.out", "JSON") + assert type(logger.handlers[0].formatter) == AEDDictToJSONFormatter + + +def test_get_logger_for_file_when_given_raw_json_format_uses_raw_json_formatter(): + logger = factory.get_logger_for_file("Test.out", "RAW-JSON") + assert type(logger.handlers[0].formatter) == AEDDictToRawJSONFormatter + + +def test_get_logger_for_file_when_called_twice_has_only_one_handler(): + _ = factory.get_logger_for_file("Test.out", "JSON") + logger = factory.get_logger_for_file("Test.out", "JSON") + assert type(logger.handlers[0].formatter) == AEDDictToJSONFormatter + + +def test_get_logger_for_file_uses_file_handler(): + logger = factory.get_logger_for_file("Test.out", "JSON") + assert type(logger.handlers[0]) == logging.FileHandler + + +def test_get_logger_for_file_uses_given_file_name(): + logger = factory.get_logger_for_file("Test.out", "JSON") + assert logger.handlers[0].baseFilename[-8:] == "Test.out" + + +def test_get_logger_for_server_has_info_level(no_priority_syslog_handler): + logger = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + assert logger.level == logging.INFO + + +def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(no_priority_syslog_handler): + factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] + _ = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + assert type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == AEDDictToCEFFormatter + + +def test_get_logger_for_server_when_given_json_format_uses_json_formatter( + no_priority_syslog_handler +): + factory.get_logger_for_server("https://example.com", "TCP", "JSON").handlers = [] + _ = factory.get_logger_for_server("https://example.com", "TCP", "JSON") + assert type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == AEDDictToJSONFormatter + + +def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter( + no_priority_syslog_handler +): + factory.get_logger_for_server("https://example.com", "TCP", "RAW-JSON").handlers = [] + _ = factory.get_logger_for_server("https://example.com", "TCP", "RAW-JSON") + assert ( + type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == AEDDictToRawJSONFormatter + ) + + +def test_get_logger_for_server_when_called_twice_only_has_one_handler(no_priority_syslog_handler): + factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] + _ = factory.get_logger_for_server("https://example.com", "TCP", "JSON") + logger = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + assert len(logger.handlers) == 1 + + +def test_get_logger_for_server_uses_no_priority_syslog_handler(no_priority_syslog_handler): + factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] + logger = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + assert logger.handlers[0] == no_priority_syslog_handler + + +def test_get_logger_for_server_uses_given_host_and_protocol(no_priority_syslog_handler): + factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] + _ = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + assert no_priority_syslog_handler.hostname.return_value == "https://example.com" + assert no_priority_syslog_handler.protocol.return_value == "TCP" + + +def test_get_error_logger_when_called_twice_only_sets_handler_once(): + _ = factory.get_error_logger() + logger = factory.get_error_logger() + assert len(logger.handlers) == 1 + + +def test_get_error_logger_uses_rotating_file_handler(): + logger = factory.get_error_logger() + assert type(logger.handlers[0]) == RotatingFileHandler diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 000000000..ebbb2a34b --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,68 @@ +import pytest + +from code42cli import main + + +@pytest.fixture +def profile(mocker): + return mocker.patch("code42cli.profile.profile.init") + + +@pytest.fixture +def checkpoint_clearer(mocker): + return mocker.patch("code42cli.securitydata.subcommands.clear_checkpoint.init") + + +@pytest.fixture +def printer(mocker): + return mocker.patch("code42cli.securitydata.subcommands.print_out.init") + + +@pytest.fixture +def writer(mocker): + return mocker.patch("code42cli.securitydata.subcommands.write_to.init") + + +@pytest.fixture +def sender(mocker): + return mocker.patch("code42cli.securitydata.subcommands.send_to.init") + + +@pytest.fixture(autouse=True) +def arg_parser(mocker): + return mocker.patch("argparse.ArgumentParser.parse_args") + + +def test_main_inits_profile(profile): + main() + assert profile.call_count == 1 + + +def test_main_inits_clear_cursor(checkpoint_clearer): + main() + assert checkpoint_clearer.call_count == 1 + + +def test_main_inits_print(printer): + main() + assert printer.call_count == 1 + + +def test_main_inits_write_to(writer): + main() + assert writer.call_count == 1 + + +def test_main_inits_send_to(sender): + main() + assert sender.call_count == 1 + + +def test_main_calls_subcommand_with_parsed_args(mocker, arg_parser, namespace): + arg_parser.return_value = namespace + namespace.format = "TEST" + namespace.exposure_types = ["EXPOSURE"] + namespace.func = mocker.MagicMock() + main() + actual_args = namespace.func.call_args[0][0] + assert actual_args == namespace diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..e5c4431f5 --- /dev/null +++ b/tox.ini @@ -0,0 +1,49 @@ +[tox] +envlist = clean,py27,py35,py36,py37,py38,report,lint27,lint37 + +# don't require all versions of python to be installed to run tests. +# the github workflow ensures that this is run with each necessary python version. +skip_missing_interpreters = true + +[testenv] +# install pytest in the virtualenv where commands will be executed. +deps = + pytest == 4.6.5 + pytest-mock == 2.0.0 + pytest-cov == 2.8.1 + # used for mocking objects in the keyring library + keyring == 18.0.1 + +commands = + # -v: verbose + # -rsxX: show extra test summary info for (s)skipped, (x)failed, (X)passed + # -l: show locals in tracebacks + # --tb=short: short traceback print mode + # --strict: marks not registered in configuration file raise errors + pytest --cov=code42cli --cov-append -v -rsxX -l --tb=short --strict + +depends = + {py27,py35,py36,py37,py38}: clean + report: py27,py35,py36,py37,py38 + +[testenv:report] +deps = coverage +skip_install = true +commands = + coverage report + coverage html + +[testenv:clean] +deps = coverage +skip_install = true +commands = coverage erase + +[testenv:lint27] +basepython = python2.7 +deps = pylint==1.9.5 +commands = pylint -E code42cli + +[testenv:lint37] +basepython = python3.7 +deps = pylint==2.4.0 +commands = pylint -E code42cli From e4c5f234a793761eb91d8195f756c3caf1fc3e75 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 25 Feb 2020 22:28:21 +0000 Subject: [PATCH 003/349] Jules/release prep (#5) --- setup.py | 2 +- tox.ini | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 34e2507c3..e04f41fcd 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ setup( name="code42cli", version=about["__version__"], - description="The office command line tool for interacting with Code42", + description="The official command line tool for interacting with Code42", long_description=readme, long_description_content_type="text/markdown", packages=find_packages("src"), diff --git a/tox.ini b/tox.ini index e5c4431f5..7f4be5ba0 100644 --- a/tox.ini +++ b/tox.ini @@ -11,8 +11,6 @@ deps = pytest == 4.6.5 pytest-mock == 2.0.0 pytest-cov == 2.8.1 - # used for mocking objects in the keyring library - keyring == 18.0.1 commands = # -v: verbose From 8cb29fef7fa14afd4ad59d1d553dcc3aa2848f24 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 25 Feb 2020 22:33:38 +0000 Subject: [PATCH 004/349] CL update (#6) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f54bc8dba..b5469a537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 0.2.0 - 2020-02-25 ### Removed @@ -30,7 +30,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Renamed base command `c42aed` to `code42`. - Moved CLI arguments `-s`, `-u`, and `--ignore-ssl-errors` to `code42 profile set` command. -- Renamed and moved top-level `-r` flag. +- Renamed and moved top-level `-r` flag. - Use `-i` on one of these `securitydata` subcommands `write-to`. `send-to`, `print`. - Moved search arguments to individual `securitydata` subcommands `write-to`. `send-to`, `print`. From 942f32e7b5a75fe3e961e1ecfa41baf071e75d95 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 2 Mar 2020 11:33:59 -0600 Subject: [PATCH 005/349] Jules/upgrades (#7) --- CHANGELOG.md | 6 + setup.py | 2 +- src/code42cli/date_helper.py | 72 +++++++++ src/code42cli/main.py | 1 + src/code42cli/profile/config.py | 2 +- .../securitydata/arguments/search.py | 6 +- src/code42cli/securitydata/extraction.py | 68 +++------ src/code42cli/securitydata/logger_factory.py | 27 ++-- .../subcommands/clear_checkpoint.py | 2 +- tests/conftest.py | 33 +++++ tests/securitydata/test_extraction.py | 140 ++++++++++-------- tests/securitydata/test_logger_factory.py | 64 ++++---- tests/test_date_helper.py | 92 ++++++++++++ 13 files changed, 351 insertions(+), 164 deletions(-) create mode 100644 src/code42cli/date_helper.py create mode 100644 tests/test_date_helper.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b5469a537..fd8ef3dcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- Begin and end date now support specifying time: `code42 securitydata print -b 2020-02-02 12:00:00`. + ## 0.2.0 - 2020-02-25 ### Removed diff --git a/setup.py b/setup.py index e04f41fcd..40ac0ede0 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ packages=find_packages("src"), package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", - install_requires=["c42eventextractor==0.1.3", "keyring==18.0.1","py42==0.4.4"], + install_requires=["c42eventextractor==0.2.0", "keyring==18.0.1","py42==0.5.1"], license="MIT", include_package_data=True, zip_safe=False, diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py new file mode 100644 index 000000000..c32ed9596 --- /dev/null +++ b/src/code42cli/date_helper.py @@ -0,0 +1,72 @@ +from datetime import datetime, timedelta +from c42eventextractor.common import convert_datetime_to_timestamp +from py42.sdk.file_event_query.event_query import EventTimestamp + +_DEFAULT_LOOK_BACK_DAYS = 60 +_MAX_LOOK_BACK_DAYS = 90 +_FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format." + + +def create_event_timestamp_range(begin_date=None, end_date=None): + min_timestamp = _parse_min_timestamp(begin_date) + max_timestamp = _parse_max_timestamp(end_date) + _verify_timestamp_order(min_timestamp, max_timestamp) + return EventTimestamp.in_range(min_timestamp, max_timestamp) + + +def _parse_min_timestamp(begin_date_str): + if not begin_date_str: + return _get_default_min_timestamp() + min_timestamp = _parse_timestamp(begin_date_str) + boundary_date = datetime.utcnow() - timedelta(days=_MAX_LOOK_BACK_DAYS) + boundary = convert_datetime_to_timestamp(boundary_date) + if min_timestamp and min_timestamp < boundary: + raise ValueError(u"'Begin date' must be within 90 days.") + return min_timestamp + + +def _parse_max_timestamp(end_date_str): + if not end_date_str: + return _get_default_max_timestamp() + return _parse_timestamp(end_date_str) + + +def _verify_timestamp_order(min_timestamp, max_timestamp): + if min_timestamp is None or max_timestamp is None: + return + if min_timestamp >= max_timestamp: + raise ValueError(u"Begin date cannot be after end date") + + +def _parse_timestamp(date_tuple): + try: + date_str = _join_date_tuple(date_tuple) + date_format = u"%Y-%m-%d" if len(date_tuple) == 1 else u"%Y-%m-%d %H:%M:%S" + time = datetime.strptime(date_str, date_format) + except ValueError: + raise ValueError(_FORMAT_VALUE_ERROR_MESSAGE) + return convert_datetime_to_timestamp(time) + + +def _join_date_tuple(date_tuple): + if not date_tuple: + return None + date_str = date_tuple[0] + if len(date_tuple) == 1: + return date_str + if len(date_tuple) == 2: + date_str = "{0} {1}".format(date_str, date_tuple[1]) + else: + raise ValueError(_FORMAT_VALUE_ERROR_MESSAGE) + return date_str + + +def _get_default_min_timestamp(): + now = datetime.utcnow() + start_day = timedelta(days=_DEFAULT_LOOK_BACK_DAYS) + days_ago = now - start_day + return convert_datetime_to_timestamp(days_ago) + + +def _get_default_max_timestamp(): + return convert_datetime_to_timestamp(datetime.utcnow()) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 576410fc5..f204e68f9 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,5 +1,6 @@ from argparse import ArgumentParser +from code42cli.compat import str from code42cli.profile import profile import code42cli.securitydata.main as securitydata diff --git a/src/code42cli/profile/config.py b/src/code42cli/profile/config.py index cbda0f3a6..b085ed329 100644 --- a/src/code42cli/profile/config.py +++ b/src/code42cli/profile/config.py @@ -21,7 +21,7 @@ def get_config_profile(): util.print_error("Profile is not set.") print("") print("To set, use: ") - util.print_bold("\tcode42cli profile set -s -u ") + util.print_bold("\tcode42 profile set -s -u ") print("") exit(1) diff --git a/src/code42cli/securitydata/arguments/search.py b/src/code42cli/securitydata/arguments/search.py index 9cfddcffc..08201ac42 100644 --- a/src/code42cli/securitydata/arguments/search.py +++ b/src/code42cli/securitydata/arguments/search.py @@ -33,10 +33,11 @@ def _add_begin_date_arg(parser): parser.add_argument( "-b", "--begin", + nargs="+", action="store", dest=SearchArguments.BEGIN_DATE, help="The beginning of the date range in which to look for events, " - "in YYYY-MM-DD UTC format OR a number (number of minutes ago).", + "in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", ) @@ -44,10 +45,11 @@ def _add_end_date_arg(parser): parser.add_argument( "-e", "--end", + nargs="+", action="store", dest=SearchArguments.END_DATE, help="The end of the date range in which to look for events, " - "in YYYY-MM-DD UTC format OR a number (number of minutes ago).", + "in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", ) diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index b3c8f1abc..a690224c0 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -1,15 +1,15 @@ from __future__ import print_function import json -from datetime import datetime, timedelta from py42.sdk import SDK from py42 import debug_level from py42 import settings -from c42eventextractor.common import FileEventHandlers -from c42eventextractor.extractors import AEDEventExtractor -from c42eventextractor.common import convert_datetime_to_timestamp +from c42eventextractor import FileEventHandlers +from c42eventextractor.extractors import FileEventExtractor +from code42cli.compat import str from code42cli.securitydata.options import ExposureType from code42cli.util import print_error +from code42cli import date_helper as date_helper from code42cli.securitydata.cursor_store import AEDCursorStore from code42cli.securitydata.logger_factory import get_error_logger from code42cli.profile.profile import get_profile @@ -21,7 +21,7 @@ def extract(output_logger, args): handlers = _create_event_handlers(output_logger, args.is_incremental) profile = get_profile() sdk = _get_sdk(profile, args.is_debug_mode) - extractor = AEDEventExtractor(sdk, handlers) + extractor = FileEventExtractor(sdk, handlers) _call_extract(extractor, args) @@ -53,44 +53,13 @@ def _get_sdk(profile, is_debug_mode): return code42 -def _parse_min_timestamp(begin_date): - min_timestamp = _parse_timestamp(begin_date) - boundary_date = datetime.utcnow() - timedelta(days=90) - boundary = convert_datetime_to_timestamp(boundary_date) - if min_timestamp and min_timestamp < boundary: - print("Argument '--begin' must be within 90 days.") - exit(1) - return min_timestamp - - -def _parse_timestamp(input_string): - try: - # Input represents date str like '2020-02-13' - time = datetime.strptime(input_string, "%Y-%m-%d") - except ValueError: - # Input represents amount of seconds ago like '86400' - if input_string and input_string.isdigit(): - now = datetime.utcnow() - time = now - timedelta(minutes=int(input_string)) - else: - raise ValueError("input must be a positive integer or a date in YYYY-MM-DD format.") - - return convert_datetime_to_timestamp(time) - - def _call_extract(extractor, args): if not _determine_if_advanced_query(args): - min_timestamp = _parse_min_timestamp(args.begin_date) if args.begin_date else None - max_timestamp = _parse_timestamp(args.end_date) if args.end_date else None + event_timestamp_filter_group = _get_event_timestamp_filter(args) _verify_exposure_types(args.exposure_types) - _verify_timestamp_order(min_timestamp, max_timestamp) - extractor.extract( - initial_min_timestamp=min_timestamp, - max_timestamp=max_timestamp, - exposure_types=args.exposure_types, - ) + extractor.extract(args.exposure_types, event_timestamp_filter_group) else: - extractor.extract_raw(args.advanced_query) + extractor.extract_advanced(args.advanced_query) def _determine_if_advanced_query(args): @@ -99,7 +68,7 @@ def _determine_if_advanced_query(args): for key in given_args: val = given_args[key] if not _verify_compatibility_with_advanced_query(key, val): - print_error("You cannot use --advanced-query with additional search args.") + print_error(u"You cannot use --advanced-query with additional search args.") exit(1) return True return False @@ -113,20 +82,19 @@ def _verify_compatibility_with_advanced_query(key, val): return True +def _get_event_timestamp_filter(args): + try: + return date_helper.create_event_timestamp_range(args.begin_date, args.end_date) + except ValueError as ex: + print_error(str(ex)) + exit(1) + + def _verify_exposure_types(exposure_types): if exposure_types is None: return options = list(ExposureType()) for exposure_type in exposure_types: if exposure_type not in options: - print_error("'{0}' is not a valid exposure type.".format(exposure_type)) + print_error(u"'{0}' is not a valid exposure type.".format(exposure_type)) exit(1) - - -def _verify_timestamp_order(begin_timestamp, end_timestamp): - if begin_timestamp is None or end_timestamp is None: - return - - if begin_timestamp >= end_timestamp: - print_error("Begin date cannot be after end date") - exit(1) diff --git a/src/code42cli/securitydata/logger_factory.py b/src/code42cli/securitydata/logger_factory.py index 4e721ab01..49494a6a4 100644 --- a/src/code42cli/securitydata/logger_factory.py +++ b/src/code42cli/securitydata/logger_factory.py @@ -2,14 +2,15 @@ import logging from logging.handlers import RotatingFileHandler from c42eventextractor.logging.formatters import ( - AEDDictToJSONFormatter, - AEDDictToCEFFormatter, - AEDDictToRawJSONFormatter, + FileEventDictToJSONFormatter, + FileEventDictToCEFFormatter, + FileEventDictToRawJSONFormatter, ) -from c42eventextractor.logging.handlers import NoPrioritySysLogHandler +from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper +from code42cli.compat import str from code42cli.securitydata.options import OutputFormat -from code42cli.util import get_user_project_path +from code42cli.util import get_user_project_path, print_error def get_logger_for_stdout(output_format): @@ -35,7 +36,7 @@ def get_logger_for_server(hostname, protocol, output_format): if _logger_has_handlers(logger): return logger - handler = NoPrioritySysLogHandler(hostname, protocol=protocol) + handler = NoPrioritySysLogHandlerWrapper(hostname, protocol=protocol).handler return _init_logger(logger, handler, output_format) @@ -62,15 +63,19 @@ def _init_logger(logger, handler, output_format): def _apply_logger_dependencies(logger, handler, formatter): - handler.setFormatter(formatter) - logger.addHandler(handler) + try: + handler.setFormatter(formatter) + logger.addHandler(handler) + except Exception as ex: + print_error(str(ex)) + exit(1) return logger def _get_formatter(output_format): if output_format == OutputFormat.JSON: - return AEDDictToJSONFormatter() + return FileEventDictToJSONFormatter() elif output_format == OutputFormat.CEF: - return AEDDictToCEFFormatter() + return FileEventDictToCEFFormatter() else: - return AEDDictToRawJSONFormatter() + return FileEventDictToRawJSONFormatter() diff --git a/src/code42cli/securitydata/subcommands/clear_checkpoint.py b/src/code42cli/securitydata/subcommands/clear_checkpoint.py index 15e1574db..4687f376c 100644 --- a/src/code42cli/securitydata/subcommands/clear_checkpoint.py +++ b/src/code42cli/securitydata/subcommands/clear_checkpoint.py @@ -12,7 +12,7 @@ def init(subcommand_parser): def clear_checkpoint(*args): """Removes the stored checkpoint that keeps track of the last event you got. - To use, do `code42cli clear-checkpoint`. + To use, run `code42 clear-checkpoint`. This affects `incremental` mode by causing it to behave like it has never been run before. """ AEDCursorStore().reset() diff --git a/tests/conftest.py b/tests/conftest.py index cefa853eb..2bbfd1eb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ import pytest +import json as json_module +from datetime import datetime, timedelta from argparse import Namespace from code42cli.profile.config import ConfigurationKeys @@ -47,3 +49,34 @@ class ConfigParserMocks(object): section_adder = None reader = None sections = None + + +def get_first_filter_value_from_json(json): + return json_module.loads(str(json))["filters"][0]["value"] + + +def get_second_filter_value_from_json(json): + return json_module.loads(str(json))["filters"][1]["value"] + + +def parse_date_from_first_filter_value(json): + date_str = get_first_filter_value_from_json(json) + return convert_str_to_date(date_str) + + +def parse_date_from_second_filter_value(json): + date_str = get_second_filter_value_from_json(json) + return convert_str_to_date(date_str) + + +def convert_str_to_date(date_str): + return datetime.strptime(date_str, u"%Y-%m-%dT%H:%M:%S.%fZ") + + +def get_test_date(days_ago): + now = datetime.utcnow() + return now - timedelta(days=days_ago) + + +def get_test_date_str(days_ago): + return get_test_date(days_ago).strftime("%Y-%m-%d") diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index a04177565..5396b0a36 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -1,9 +1,17 @@ import pytest -from datetime import datetime, timedelta +from datetime import datetime from code42cli.securitydata.options import ExposureType -from .conftest import ROOT_PATH from code42cli.securitydata.extraction import extract +from .conftest import ROOT_PATH +from ..conftest import ( + get_first_filter_value_from_json, + get_second_filter_value_from_json, + parse_date_from_first_filter_value, + parse_date_from_second_filter_value, + get_test_date, + get_test_date_str, +) @pytest.fixture(autouse=True) @@ -24,18 +32,13 @@ def error_logger(mocker): return mocker.patch("{0}.logger_factory".format(ROOT_PATH)) -@pytest.fixture -def cursor_store(mocker): - mock = mocker.patch("c42eventextractor.extractors.AEDCursorStore.__init__") - mock.return_value = None - return mock - - @pytest.fixture(autouse=True) def extractor(mocker): mock = mocker.MagicMock() - mock.extract_raw = mocker.patch("c42eventextractor.extractors.AEDEventExtractor.extract_raw") - mock.extract = mocker.patch("c42eventextractor.extractors.AEDEventExtractor.extract") + mock.extract_advanced = mocker.patch( + "c42eventextractor.extractors.FileEventExtractor.extract_advanced" + ) + mock.extract = mocker.patch("c42eventextractor.extractors.FileEventExtractor.extract") return mock @@ -44,28 +47,12 @@ def profile(mocker): mocker.patch("code42cli.securitydata.extraction.get_profile") -def get_test_date_str(days_ago): - now = datetime.utcnow() - days_ago_date = now - timedelta(days=days_ago) - return days_ago_date.strftime("%Y-%m-%d") - - -def get_timestamp_from_date_str(date_str): - date = datetime.strptime(date_str, "%Y-%m-%d") - return (date - datetime.utcfromtimestamp(0)).total_seconds() - - -def get_timestamp_from_seconds_ago(seconds_ago): - date = datetime.utcnow() - timedelta(seconds=seconds_ago) - return (date - datetime.utcfromtimestamp(0)).total_seconds() - - -def test_extract_when_is_advanced_query_uses_only_the_extract_raw_method( +def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( logger, namespace, extractor ): namespace.advanced_query = "some complex json" extract(logger, namespace) - extractor.extract_raw.assert_called_once_with("some complex json") + extractor.extract_advanced.assert_called_once_with("some complex json") assert extractor.extract.call_count == 0 @@ -118,76 +105,87 @@ def test_extract_passed_through_given_exposure_types(logger, error_logger, names ExposureType.APPLICATION_READ, ] extract(logger, namespace) - assert extractor.extract.call_args[1]["exposure_types"] == [ + assert extractor.extract.call_args[0][0] == [ ExposureType.IS_PUBLIC, ExposureType.CLOUD_STORAGE, ExposureType.APPLICATION_READ, ] -def test_extract_when_given_begin_date_uses_expected_begin_timestamp( +def test_extract_when_not_given_begin_or_end_dates_uses_default_query( logger, error_logger, namespace, extractor ): - test_begin_date_str = get_test_date_str(days_ago=89) - namespace.begin_date = test_begin_date_str + namespace.begin_date = None + namespace.end_date = None extract(logger, namespace) - expected_begin_timestamp = get_timestamp_from_date_str(test_begin_date_str) - actual_begin_timestamp = extractor.extract.call_args[1]["initial_min_timestamp"] - assert actual_begin_timestamp == expected_begin_timestamp + actual_begin = parse_date_from_first_filter_value(extractor.extract.call_args[0][1]) + actual_end = parse_date_from_second_filter_value(extractor.extract.call_args[0][1]) + expected_begin = get_test_date(days_ago=60) + expected_end = datetime.utcnow() + assert (expected_begin - actual_begin).total_seconds() < 0.1 + assert (expected_end - actual_end).total_seconds() < 0.1 -def test_extract_when_given_begin_date_as_seconds_ago_uses_expected_begin_timestamp( +def test_extract_when_given_begin_date_uses_expected_query( logger, error_logger, namespace, extractor ): - namespace.begin_date = "600" + namespace.begin_date = (get_test_date_str(days_ago=89),) extract(logger, namespace) - expected_timestamp = get_timestamp_from_seconds_ago(600) - actual_timestamp = extractor.extract.call_args[1]["initial_min_timestamp"] - assert pytest.approx(expected_timestamp, actual_timestamp) + actual = get_first_filter_value_from_json(extractor.extract.call_args[0][1]) + expected = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) + assert actual == expected -def test_extract_when_given_end_date_uses_expected_begin_timestamp( +def test_extract_when_given_begin_date_and_time_uses_expected_query( logger, error_logger, namespace, extractor ): - test_end_date_str = get_test_date_str(days_ago=10) - namespace.end_date = test_end_date_str + namespace.begin_date = (get_test_date_str(days_ago=89), "15:33:02") extract(logger, namespace) - expected_end_timestamp = get_timestamp_from_date_str(test_end_date_str) - actual_end_timestamp = extractor.extract.call_args[1]["max_timestamp"] - assert actual_end_timestamp == expected_end_timestamp + actual = get_first_filter_value_from_json(extractor.extract.call_args[0][1]) + expected = "{0}T{1}.000Z".format(namespace.begin_date[0], namespace.begin_date[1]) + assert actual == expected + + +def test_extract_when_given_end_date_uses_expected_query( + logger, error_logger, namespace, extractor +): + namespace.end_date = (get_test_date_str(days_ago=10),) + extract(logger, namespace) + actual = get_second_filter_value_from_json(extractor.extract.call_args[0][1]) + expected = "{0}T00:00:00.000Z".format(namespace.end_date[0]) + assert actual == expected -def test_extract_when_given_end_date_as_seconds_ago_uses_expected_begin_timestamp( +def test_extract_when_given_end_date_and_time_uses_expected_query( logger, error_logger, namespace, extractor ): - namespace.end_date = "600" + namespace.end_date = (get_test_date_str(days_ago=10), "12:00:11") extract(logger, namespace) - expected_timestamp = get_timestamp_from_seconds_ago(600) - actual_timestamp = extractor.extract.call_args[1]["max_timestamp"] - assert pytest.approx(expected_timestamp, actual_timestamp) + actual = get_second_filter_value_from_json(extractor.extract.call_args[0][1]) + expected = "{0}T{1}.000Z".format(namespace.end_date[0], namespace.end_date[1]) + assert actual == expected def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( logger, error_logger, namespace, extractor ): - test_begin_date_str = get_test_date_str(days_ago=89) - namespace.begin_date = test_begin_date_str - namespace.end_date = "600" + namespace.begin_date = (get_test_date_str(days_ago=89),) + namespace.end_date = (get_test_date_str(days_ago=55), "13:44:44") extract(logger, namespace) - expected_begin_timestamp = get_timestamp_from_date_str(test_begin_date_str) - expected_end_timestamp = get_timestamp_from_seconds_ago(600) - actual_begin_timestamp = extractor.extract.call_args[1]["initial_min_timestamp"] - actual_end_timestamp = extractor.extract.call_args[1]["max_timestamp"] + actual_begin_timestamp = get_first_filter_value_from_json(extractor.extract.call_args[0][1]) + actual_end_timestamp = get_second_filter_value_from_json(extractor.extract.call_args[0][1]) + expected_begin_timestamp = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) + expected_end_timestamp = "{0}T{1}.000Z".format(namespace.end_date[0], namespace.end_date[1]) assert actual_begin_timestamp == expected_begin_timestamp - assert pytest.approx(expected_end_timestamp, actual_end_timestamp) + assert actual_end_timestamp == expected_end_timestamp def test_extract_when_given_min_timestamp_more_than_ninety_days_back_causes_exit( logger, error_logger, namespace, extractor ): - namespace.begin_date = get_test_date_str(days_ago=91) + namespace.begin_date = (get_test_date_str(days_ago=91), "12:51:00") with pytest.raises(SystemExit): extract(logger, namespace) @@ -195,8 +193,8 @@ def test_extract_when_given_min_timestamp_more_than_ninety_days_back_causes_exit def test_extract_when_end_date_is_before_begin_date_causes_exit( logger, error_logger, namespace, extractor ): - namespace.begin_date = get_test_date_str(days_ago=5) - namespace.end_date = get_test_date_str(days_ago=6) + namespace.begin_date = (get_test_date_str(days_ago=5),) + namespace.end_date = (get_test_date_str(days_ago=6),) with pytest.raises(SystemExit): extract(logger, namespace) @@ -211,3 +209,19 @@ def test_extract_when_given_invalid_exposure_type_causes_exit( ] with pytest.raises(SystemExit): extract(logger, namespace) + + +def test_extract_when_given_begin_date_with_len_3_causes_exit( + logger, error_logger, namespace, extractor +): + namespace.begin_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_given_end_date_with_len_3_causes_exit( + logger, error_logger, namespace, extractor +): + namespace.end_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") + with pytest.raises(SystemExit): + extract(logger, namespace) diff --git a/tests/securitydata/test_logger_factory.py b/tests/securitydata/test_logger_factory.py index ccbb4899e..4c5ed51b5 100644 --- a/tests/securitydata/test_logger_factory.py +++ b/tests/securitydata/test_logger_factory.py @@ -2,30 +2,21 @@ import logging from logging.handlers import RotatingFileHandler from c42eventextractor.logging.formatters import ( - AEDDictToCEFFormatter, - AEDDictToJSONFormatter, - AEDDictToRawJSONFormatter, + FileEventDictToCEFFormatter, + FileEventDictToJSONFormatter, + FileEventDictToRawJSONFormatter, ) import code42cli.securitydata.logger_factory as factory -_SYSLOG_HANDLER_PATH = "c42eventextractor.logging.handlers.NoPrioritySysLogHandler" - - @pytest.fixture def no_priority_syslog_handler(mocker): - mock_no_priority_syslog_handler = mocker.MagicMock() - mock_new = mocker.patch("{0}.__new__".format(_SYSLOG_HANDLER_PATH)) + mock = mocker.patch("c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.handler") - def set_mock(_, hostname, protocol): - mock_no_priority_syslog_handler.hostname.return_value = hostname - mock_no_priority_syslog_handler.protocol.return_value = protocol - return mock_no_priority_syslog_handler - - mock_new.side_effect = set_mock - mock_new.return_value = mock_no_priority_syslog_handler - return mock_no_priority_syslog_handler + # Set handlers to empty list so it gets initialized each test + factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] + return mock def test_get_logger_for_stdout_has_info_level(): @@ -35,17 +26,17 @@ def test_get_logger_for_stdout_has_info_level(): def test_get_logger_for_stdout_when_given_cef_format_uses_cef_formatter(): logger = factory.get_logger_for_stdout("CEF") - assert type(logger.handlers[0].formatter) == AEDDictToCEFFormatter + assert type(logger.handlers[0].formatter) == FileEventDictToCEFFormatter def test_get_logger_for_stdout_when_given_json_format_uses_json_formatter(): logger = factory.get_logger_for_stdout("JSON") - assert type(logger.handlers[0].formatter) == AEDDictToJSONFormatter + assert type(logger.handlers[0].formatter) == FileEventDictToJSONFormatter def test_get_logger_for_stdout_when_given_raw_json_format_uses_raw_json_formatter(): logger = factory.get_logger_for_stdout("RAW-JSON") - assert type(logger.handlers[0].formatter) == AEDDictToRawJSONFormatter + assert type(logger.handlers[0].formatter) == FileEventDictToRawJSONFormatter def test_get_logger_for_stdout_when_called_twice_has_only_one_handler(): @@ -66,23 +57,23 @@ def test_get_logger_for_file_has_info_level(): def test_get_logger_for_file_when_given_cef_format_uses_cef_formatter(): logger = factory.get_logger_for_file("Test.out", "CEF") - assert type(logger.handlers[0].formatter) == AEDDictToCEFFormatter + assert type(logger.handlers[0].formatter) == FileEventDictToCEFFormatter def test_get_logger_for_file_when_given_json_format_uses_json_formatter(): logger = factory.get_logger_for_file("Test.out", "JSON") - assert type(logger.handlers[0].formatter) == AEDDictToJSONFormatter + assert type(logger.handlers[0].formatter) == FileEventDictToJSONFormatter def test_get_logger_for_file_when_given_raw_json_format_uses_raw_json_formatter(): logger = factory.get_logger_for_file("Test.out", "RAW-JSON") - assert type(logger.handlers[0].formatter) == AEDDictToRawJSONFormatter + assert type(logger.handlers[0].formatter) == FileEventDictToRawJSONFormatter def test_get_logger_for_file_when_called_twice_has_only_one_handler(): _ = factory.get_logger_for_file("Test.out", "JSON") logger = factory.get_logger_for_file("Test.out", "JSON") - assert type(logger.handlers[0].formatter) == AEDDictToJSONFormatter + assert type(logger.handlers[0].formatter) == FileEventDictToJSONFormatter def test_get_logger_for_file_uses_file_handler(): @@ -101,9 +92,10 @@ def test_get_logger_for_server_has_info_level(no_priority_syslog_handler): def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(no_priority_syslog_handler): - factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] _ = factory.get_logger_for_server("https://example.com", "TCP", "CEF") - assert type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == AEDDictToCEFFormatter + assert ( + type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == FileEventDictToCEFFormatter + ) def test_get_logger_for_server_when_given_json_format_uses_json_formatter( @@ -111,7 +103,8 @@ def test_get_logger_for_server_when_given_json_format_uses_json_formatter( ): factory.get_logger_for_server("https://example.com", "TCP", "JSON").handlers = [] _ = factory.get_logger_for_server("https://example.com", "TCP", "JSON") - assert type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == AEDDictToJSONFormatter + actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) + assert actual == FileEventDictToJSONFormatter def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter( @@ -119,29 +112,30 @@ def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatte ): factory.get_logger_for_server("https://example.com", "TCP", "RAW-JSON").handlers = [] _ = factory.get_logger_for_server("https://example.com", "TCP", "RAW-JSON") - assert ( - type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == AEDDictToRawJSONFormatter - ) + actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) + assert actual == FileEventDictToRawJSONFormatter def test_get_logger_for_server_when_called_twice_only_has_one_handler(no_priority_syslog_handler): - factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] _ = factory.get_logger_for_server("https://example.com", "TCP", "JSON") logger = factory.get_logger_for_server("https://example.com", "TCP", "CEF") assert len(logger.handlers) == 1 def test_get_logger_for_server_uses_no_priority_syslog_handler(no_priority_syslog_handler): - factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] logger = factory.get_logger_for_server("https://example.com", "TCP", "CEF") assert logger.handlers[0] == no_priority_syslog_handler -def test_get_logger_for_server_uses_given_host_and_protocol(no_priority_syslog_handler): - factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] +def test_get_logger_for_server_uses_given_host_and_protocol(mocker, no_priority_syslog_handler): + no_priority_syslog_handler_wrapper = mocker.patch( + "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" + ) + no_priority_syslog_handler_wrapper.return_value = None _ = factory.get_logger_for_server("https://example.com", "TCP", "CEF") - assert no_priority_syslog_handler.hostname.return_value == "https://example.com" - assert no_priority_syslog_handler.protocol.return_value == "TCP" + no_priority_syslog_handler_wrapper.assert_called_once_with( + "https://example.com", protocol="TCP" + ) def test_get_error_logger_when_called_twice_only_sets_handler_once(): diff --git a/tests/test_date_helper.py b/tests/test_date_helper.py new file mode 100644 index 000000000..718c32db5 --- /dev/null +++ b/tests/test_date_helper.py @@ -0,0 +1,92 @@ +import pytest +from datetime import datetime + +from code42cli.date_helper import create_event_timestamp_range +from .conftest import ( + get_first_filter_value_from_json, + get_second_filter_value_from_json, + parse_date_from_first_filter_value, + parse_date_from_second_filter_value, + get_test_date, + get_test_date_str, +) + + +def test_create_event_timestamp_range_when_given_no_args_creates_range_from_sixty_days_back_to_now(): + ts_range = create_event_timestamp_range() + actual_begin = parse_date_from_first_filter_value(ts_range) + actual_end = parse_date_from_second_filter_value(ts_range) + expected_begin = get_test_date(days_ago=60) + expected_end = datetime.utcnow() + assert (expected_begin - actual_begin).total_seconds() < 0.1 + assert (expected_end - actual_end).total_seconds() < 0.1 + + +def test_create_event_timestamp_range_when_given_begin_builds_expected_query(): + begin_date_tuple = (get_test_date_str(days_ago=89),) + ts_range = create_event_timestamp_range(begin_date_tuple) + actual = get_first_filter_value_from_json(ts_range) + expected = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) + assert actual == expected + + +def test_create_event_timestamp_range_when_given_begin_with_time_builds_expected_query(): + time_str = "3:12:33" + begin_date_tuple = (get_test_date_str(days_ago=89), time_str) + ts_range = create_event_timestamp_range(begin_date_tuple) + actual = get_first_filter_value_from_json(ts_range) + expected = "{0}T0{1}.000Z".format(begin_date_tuple[0], time_str) + assert actual == expected + + +def test_create_event_timestamp_range_when_given_end_builds_expected_query(): + end_date_tuple = (get_test_date_str(days_ago=10),) + ts_range = create_event_timestamp_range(None, end_date_tuple) + actual = get_second_filter_value_from_json(ts_range) + expected = "{0}T00:00:00.000Z".format(end_date_tuple[0]) + assert actual == expected + + +def test_create_event_timestamp_range_when_given_end_with_time_builds_expected_query(): + time_str = "11:22:43" + end_date_tuple = (get_test_date_str(days_ago=10), time_str) + ts_range = create_event_timestamp_range(None, end_date_tuple) + actual = get_second_filter_value_from_json(ts_range) + expected = "{0}T{1}.000Z".format(end_date_tuple[0], time_str) + assert actual == expected + + +def test_create_event_timestamp_range_when_given_both_begin_and_end_builds_expected_query(): + begin_date_tuple = (get_test_date_str(days_ago=89),) + end_date_tuple = (get_test_date_str(days_ago=55), "12:00:00") + ts_range = create_event_timestamp_range(begin_date_tuple, end_date_tuple) + actual_begin = get_first_filter_value_from_json(ts_range) + actual_end = get_second_filter_value_from_json(ts_range) + expected_begin = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) + expected_end = "{0}T{1}.000Z".format(end_date_tuple[0], end_date_tuple[1]) + assert actual_begin == expected_begin + assert actual_end == expected_end + + +def test_create_event_timestamp_range_when_begin_more_than_ninety_days_back_causes_value_error(): + begin_date_tuple = (get_test_date_str(days_ago=91),) + with pytest.raises(ValueError): + create_event_timestamp_range(begin_date_tuple) + + +def test_create_event_timestamp_when_end_is_before_begin_causes_value_error(): + begin_date_tuple = (get_test_date_str(days_ago=5),) + end_date_str = (get_test_date_str(days_ago=7),) + with pytest.raises(ValueError): + create_event_timestamp_range(begin_date_tuple, end_date_str) + + +def test_create_event_timestamp_when_given_minutes_ago_and_time_raises_value_error(): + with pytest.raises(ValueError): + create_event_timestamp_range("600", "12:00:00") + + +def test_create_event_timestamp_when_given_three_date_args_raises_value_error(): + begin_date_tuple = (get_test_date_str(days_ago=5), "12:00:00", "end_date=12:00:00") + with pytest.raises(ValueError): + create_event_timestamp_range(begin_date_tuple) From f3fb4a95548e67c0ac7381d068b928eb203bee5e Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 4 Mar 2020 15:20:45 -0600 Subject: [PATCH 006/349] Feature/support new search params (#8) --- CHANGELOG.md | 24 +- README.md | 22 +- setup.py | 2 +- src/code42cli/__version__.py | 2 +- src/code42cli/main.py | 4 +- src/code42cli/profile/config.py | 65 ++-- src/code42cli/profile/password.py | 15 +- src/code42cli/profile/profile.py | 124 +++---- src/code42cli/securitydata/arguments/main.py | 30 +- .../securitydata/arguments/search.py | 184 ++++++++-- src/code42cli/securitydata/cursor_store.py | 29 +- .../{ => securitydata}/date_helper.py | 27 +- src/code42cli/securitydata/extraction.py | 84 ++++- src/code42cli/securitydata/logger_factory.py | 65 +++- .../securitydata/subcommands/print_out.py | 2 +- src/code42cli/util.py | 1 + tests/conftest.py | 27 +- tests/profile/conftest.py | 3 + tests/profile/test_config.py | 44 ++- tests/profile/test_password.py | 23 +- tests/profile/test_profile.py | 166 ++++----- tests/securitydata/conftest.py | 12 +- tests/securitydata/subcommands/conftest.py | 34 ++ .../subcommands/test_clear_checkpoint.py | 4 +- .../subcommands/test_print_out.py | 23 +- .../securitydata/subcommands/test_send_to.py | 25 +- .../securitydata/subcommands/test_write_to.py | 25 +- tests/securitydata/test_date_helper.py | 72 ++++ tests/securitydata/test_extraction.py | 322 +++++++++++++----- tests/test_date_helper.py | 92 ----- 30 files changed, 958 insertions(+), 594 deletions(-) rename src/code42cli/{ => securitydata}/date_helper.py (73%) create mode 100644 tests/profile/conftest.py create mode 100644 tests/securitydata/subcommands/conftest.py create mode 100644 tests/securitydata/test_date_helper.py delete mode 100644 tests/test_date_helper.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8ef3dcd..c6d48c02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 0.3.0 - 2020-03-04 ### Added - Begin and end date now support specifying time: `code42 securitydata print -b 2020-02-02 12:00:00`. +- New search arguments for `print`, `write-to`, and `send-to`: + - `--c42username` + - `--actor` + - `--md5` + - `--sha256` + - `--source` + - `--filename` + - `--filepath` + - `--processOwner` + - `--tabURL` + - `--include-non-exposure` + +### Changed + +- It is no longer required to store your password in your profile, + and you will be prompted to enter your password at runtime if you don't. +- You will be asked if you would like to set a password after using `code42cli profile set`. +- Begin date is now required for `securitydata` `print`, `write-to`, and `send-to` commands. + +### Removed + +- Removed `--show` flag from `code42 profile set` command. Just use `code42 profile show`. ## 0.2.0 - 2020-02-25 diff --git a/README.md b/README.md index 82672e44e..6c907eb48 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # The Code42 CLI Use the `code42` command to interact with your Code42 environment. -`code42 securitydata` is a CLI tool for extracting AED events. +`code42 securitydata` is a CLI tool for extracting AED events. Additionally, `code42 securitydata` can record a checkpoint so that you only get events you have not previously gotten. ## Requirements @@ -23,15 +23,11 @@ First, set your profile: code42 profile set -s https://example.authority.com -u security.admin@example.com ``` Your profile contains the necessary properties for logging into Code42 servers. -You will prompted for a password if there is not one saved for your current username/authority URL combination. - -To explicitly set your password, use `-p`: -```bash -code42 profile set -p -``` -You will be securely prompted to input your password. +After running this `code42 profile set`, you will be prompted about storing a password. +If you agree, you will be securely prompted to input your password. Your password is not stored in plain-text, and is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that there is a password set for your profile. +If you do not set a password, you will be securely prompted to enter a password each time you run a command. To ignore SSL errors, do: ```bash @@ -67,6 +63,16 @@ Each destination-type subcommand shares query parameters * `-t` (exposure types) * `-b` (begin date) * `-e` (end date) +* `--c42username` +* `--actor` +* `--md5` +* `--sha256` +* `--source` +* `--filename` +* `--filepath` +* `--processOwner` +* `--tabURL` +* `--include-non-exposure` (does not work with `-t`) * `--advanced-query` (raw JSON query) Note that you cannot use other query parameters if you use `--advanced-query`. diff --git a/setup.py b/setup.py index 40ac0ede0..61b7627b3 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ packages=find_packages("src"), package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", - install_requires=["c42eventextractor==0.2.0", "keyring==18.0.1","py42==0.5.1"], + install_requires=["c42eventextractor==0.2.1", "keyring==18.0.1","py42==0.5.1"], license="MIT", include_package_data=True, zip_safe=False, diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index d3ec452c3..493f7415d 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/src/code42cli/main.py b/src/code42cli/main.py index f204e68f9..dd9547f24 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -10,10 +10,10 @@ def main(): subcommand_parser = code42_arg_parser.add_subparsers() profile.init(subcommand_parser) securitydata.init_subcommand(subcommand_parser) - _call_subcommand(code42_arg_parser) + _run(code42_arg_parser) -def _call_subcommand(parser): +def _run(parser): try: args = parser.parse_args() args.func(args) diff --git a/src/code42cli/profile/config.py b/src/code42cli/profile/config.py index b085ed329..9a56daa80 100644 --- a/src/code42cli/profile/config.py +++ b/src/code42cli/profile/config.py @@ -6,6 +6,9 @@ import code42cli.util as util +_DEFAULT_VALUE = u"__DEFAULT__" + + class ConfigurationKeys(object): USER_SECTION = u"Code42" AUTHORITY_KEY = u"c42_authority_url" @@ -16,33 +19,37 @@ class ConfigurationKeys(object): def get_config_profile(): + """Get your config file profile.""" parser = ConfigParser() if not profile_has_been_set(): - util.print_error("Profile is not set.") - print("") - print("To set, use: ") - util.print_bold("\tcode42 profile set -s -u ") - print("") + util.print_error(u"Profile has not completed setup.") + print(u"") + print(u"To set, use: ") + util.print_bold(u"\tcode42 profile set -s -u ") + print(u"") exit(1) return _get_config_profile_from_parser(parser) -def mark_as_set(): +def profile_has_been_set(): + """Whether you have, at one point in time, set your username and authority server URL.""" parser = ConfigParser() config_file_path = _get_config_file_path() parser.read(config_file_path) settings = parser[ConfigurationKeys.INTERNAL_SECTION] - settings[ConfigurationKeys.HAS_SET_PROFILE_KEY] = "True" - _save(parser, ConfigurationKeys.HAS_SET_PROFILE_KEY) + return settings.getboolean(ConfigurationKeys.HAS_SET_PROFILE_KEY) -def profile_has_been_set(): +def mark_as_set_if_complete(): + if not _profile_can_be_set(): + return parser = ConfigParser() config_file_path = _get_config_file_path() parser.read(config_file_path) settings = parser[ConfigurationKeys.INTERNAL_SECTION] - return settings.getboolean(ConfigurationKeys.HAS_SET_PROFILE_KEY) + settings[ConfigurationKeys.HAS_SET_PROFILE_KEY] = u"True" + _save(parser, ConfigurationKeys.HAS_SET_PROFILE_KEY) def set_username(new_username): @@ -66,22 +73,31 @@ def set_ignore_ssl_errors(new_value): _save(parser, ConfigurationKeys.IGNORE_SSL_ERRORS_KEY) -def _get_config_file_path(): - path = "{}config.cfg".format(util.get_user_project_path()) - if not os.path.exists(path) or not _verify_config_file(path): - _create_new_config_file(path) - - return path +def _profile_can_be_set(): + """Whether your current username and authority URL are set, + but your profile has not been marked as set. + """ + parser = ConfigParser() + profile = _get_config_profile_from_parser(parser) + username = profile[ConfigurationKeys.USERNAME_KEY] + authority = profile[ConfigurationKeys.AUTHORITY_KEY] + return username != _DEFAULT_VALUE and authority != _DEFAULT_VALUE and not profile_has_been_set() def _get_config_profile_from_parser(parser): config_file_path = _get_config_file_path() parser.read(config_file_path) config = parser[ConfigurationKeys.USER_SECTION] - config.ignore_ssl_errors = config.getboolean(ConfigurationKeys.IGNORE_SSL_ERRORS_KEY) return config +def _get_config_file_path(): + path = u"{}config.cfg".format(util.get_user_project_path()) + if not os.path.exists(path) or not _verify_config_file(path): + _create_new_config_file(path) + return path + + def _create_new_config_file(path): config_parser = ConfigParser() config_parser = _create_user_section(config_parser) @@ -93,9 +109,9 @@ def _create_user_section(parser): keys = ConfigurationKeys parser.add_section(keys.USER_SECTION) parser[keys.USER_SECTION] = {} - parser[keys.USER_SECTION][keys.AUTHORITY_KEY] = "null" - parser[keys.USER_SECTION][keys.USERNAME_KEY] = "null" - parser[keys.USER_SECTION][keys.IGNORE_SSL_ERRORS_KEY] = "False" + parser[keys.USER_SECTION][keys.AUTHORITY_KEY] = _DEFAULT_VALUE + parser[keys.USER_SECTION][keys.USERNAME_KEY] = _DEFAULT_VALUE + parser[keys.USER_SECTION][keys.IGNORE_SSL_ERRORS_KEY] = u"False" return parser @@ -103,15 +119,18 @@ def _create_internal_section(parser): keys = ConfigurationKeys parser.add_section(keys.INTERNAL_SECTION) parser[keys.INTERNAL_SECTION] = {} - parser[keys.INTERNAL_SECTION][keys.HAS_SET_PROFILE_KEY] = "False" + parser[keys.INTERNAL_SECTION][keys.HAS_SET_PROFILE_KEY] = u"False" return parser def _save(parser, key=None, path=None): path = _get_config_file_path() if path is None else path - util.open_file(path, "w+", lambda f: parser.write(f)) + util.open_file(path, u"w+", lambda f: parser.write(f)) if key is not None: - print("'{}' has been successfully updated".format(key)) + if key == ConfigurationKeys.HAS_SET_PROFILE_KEY: + print(u"You have completed setting up your profile!") + else: + print(u"'{}' has been successfully updated".format(key)) def _verify_config_file(path): diff --git a/src/code42cli/profile/password.py b/src/code42cli/profile/password.py index e21c7a8ec..f96199547 100644 --- a/src/code42cli/profile/password.py +++ b/src/code42cli/profile/password.py @@ -9,32 +9,33 @@ _ROOT_SERVICE_NAME = u"code42cli" -def get_password(prompt_if_not_exists=True): +def get_password(): """Gets your currently stored password for your username / authority URL combo.""" profile = config.get_config_profile() service_name = _get_service_name(profile) username = _get_username(profile) password = keyring.get_password(service_name, username) - if password is None and prompt_if_not_exists: - return set_password() - return password -def set_password(): +def set_password_from_prompt(): """Prompts and sets your password for your username / authority URL combo.""" password = getpass() profile = config.get_config_profile() service_name = _get_service_name(profile) username = _get_username(profile) keyring.set_password(service_name, username, password) - print("'Code42 Password' updated.") + print(u"'Code42 Password' updated.") return password +def get_password_from_prompt(): + return getpass() + + def _get_service_name(profile): authority_url = profile[ConfigurationKeys.AUTHORITY_KEY] - return "{}::{}".format(_ROOT_SERVICE_NAME, authority_url) + return u"{}::{}".format(_ROOT_SERVICE_NAME, authority_url) def _get_username(profile): diff --git a/src/code42cli/profile/profile.py b/src/code42cli/profile/profile.py index e7ac8db7c..75377a0a3 100644 --- a/src/code42cli/profile/profile.py +++ b/src/code42cli/profile/profile.py @@ -3,14 +3,20 @@ import code42cli.profile.config as config import code42cli.profile.password as password from code42cli.profile.config import ConfigurationKeys -from code42cli.util import print_error +from code42cli.util import get_input class Code42Profile(object): - authority_url = "" - username = "" + authority_url = u"" + username = u"" ignore_ssl_errors = False - get_password = password.get_password + + @staticmethod + def get_password(): + pwd = password.get_password() + if not pwd: + pwd = password.get_password_from_prompt() + return pwd def init(subcommand_parser): @@ -20,20 +26,21 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser_profile = subcommand_parser.add_parser("profile") + parser_profile = subcommand_parser.add_parser(u"profile") parser_profile.set_defaults(func=show_profile) profile_subparsers = parser_profile.add_subparsers() - parser_for_show_command = profile_subparsers.add_parser("show") - parser_for_set_command = profile_subparsers.add_parser("set") + parser_for_show_command = profile_subparsers.add_parser(u"show") + parser_for_set_command = profile_subparsers.add_parser(u"set") + parser_for_reset_password = profile_subparsers.add_parser(u"reset-pw") parser_for_show_command.set_defaults(func=show_profile) parser_for_set_command.set_defaults(func=set_profile) + parser_for_reset_password.set_defaults(func=prompt_for_password_reset) _add_args_to_set_command(parser_for_set_command) def get_profile(): - # type: () -> Code42Profile """Returns the current profile object.""" profile_values = config.get_config_profile() profile = Code42Profile() @@ -46,98 +53,73 @@ def get_profile(): def show_profile(*args): """Prints the current profile to stdout.""" profile = config.get_config_profile() - print("\nProfile:") + print(u"\nProfile:") for key in profile: - print("\t* {} = {}".format(key, profile[key])) - - # Don't prompt here because it may be confusing from a UX perspective - if password.get_password(prompt_if_not_exists=False) is not None: - print("\t* A password is set.") + print(u"\t* {} = {}".format(key, profile[key])) - print("") + if password.get_password() is not None: + print(u"\t* A password is set.") + print(u"") def set_profile(args): """Sets the current profile using command line arguments.""" - if not _verify_args_for_initial_profile_set(args): - exit(1) - elif not config.profile_has_been_set(): - config.mark_as_set() - _try_set_authority_url(args) _try_set_username(args) _try_set_ignore_ssl_errors(args) - _try_set_password(args) + config.mark_as_set_if_complete() + _prompt_for_allow_password_set() + - if args.show: - show_profile() +def prompt_for_password_reset(*args): + """Securely prompts for your password and then stores it using keyring.""" + password.set_password_from_prompt() def _add_args_to_set_command(parser_for_set_command): _add_authority_arg(parser_for_set_command) _add_username_arg(parser_for_set_command) - _add_password_arg(parser_for_set_command) _add_disable_ssl_errors_arg(parser_for_set_command) _add_enable_ssl_errors_arg(parser_for_set_command) - _add_show_arg(parser_for_set_command) def _add_authority_arg(parser): parser.add_argument( - "-s", - "--server", - action="store", + u"-s", + u"--server", + action=u"store", dest=ConfigurationKeys.AUTHORITY_KEY, - help="The full scheme, url and port of the Code42 server.", + help=u"The full scheme, url and port of the Code42 server.", ) def _add_username_arg(parser): parser.add_argument( - "-u", - "--username", - action="store", + u"-u", + u"--username", + action=u"store", dest=ConfigurationKeys.USERNAME_KEY, - help="The username of the Code42 API user.", - ) - - -def _add_password_arg(parser): - parser.add_argument( - "-p", - "--password", - action="store_true", - dest="do_set_c42_password", - help="The password for the Code42 API user. Passwords are not stored in plain text.", + help=u"The username of the Code42 API user.", ) def _add_disable_ssl_errors_arg(parser): parser.add_argument( - "--disable-ssl-errors", - action="store_true", + u"--disable-ssl-errors", + action=u"store_true", default=None, - dest="disable_ssl_errors", - help="Do not validate the SSL certificates of Code42 servers.", + dest=u"disable_ssl_errors", + help=u"Do not validate the SSL certificates of Code42 servers.", ) def _add_enable_ssl_errors_arg(parser): parser.add_argument( - "--enable-ssl-errors", - action="store_true", + u"--enable-ssl-errors", + action=u"store_true", default=None, - dest="enable_ssl_errors", - help="Do validate the SSL certificates of Code42 servers.", - ) - - -def _add_show_arg(parser): - parser.add_argument( - "--show", - action="store_true", - dest="show", - help="Whether to show the profile after setting it.", + dest=u"enable_ssl_errors", + help=u"Do validate the SSL certificates of Code42 servers.", ) @@ -163,24 +145,10 @@ def _try_set_ignore_ssl_errors(args): config.set_ignore_ssl_errors(False) -def _try_set_password(args): - if args.do_set_c42_password: - password.set_password() - - # Prompt for password if it does not exist for the current username / authority host address combo. - password.get_password() - - -def _verify_args_for_initial_profile_set(args): - if not config.profile_has_been_set() and ( - args.c42_username is None or args.c42_authority_url is None - ): - if args.c42_username is None: - print_error("Missing username argument.") - if args.c42_authority_url is None: - print_error("Missing Code42 Authority URL argument.") - return False - return True +def _prompt_for_allow_password_set(): + answer = get_input(u"Would you like to set a password? (y/n): ") + if answer.lower() == u"y": + prompt_for_password_reset() if __name__ == "__main__": diff --git a/src/code42cli/securitydata/arguments/main.py b/src/code42cli/securitydata/arguments/main.py index fd08af3e0..11608344f 100644 --- a/src/code42cli/securitydata/arguments/main.py +++ b/src/code42cli/securitydata/arguments/main.py @@ -1,38 +1,42 @@ from code42cli.securitydata.options import OutputFormat -IS_INCREMENTAL_KEY = "is_incremental" +IS_INCREMENTAL_KEY = u"is_incremental" def add_arguments_to_parser(parser): _add_output_format_arg(parser) _add_incremental_arg(parser) - _add_debug_args(parser) + _add_debug_arg(parser) def _add_output_format_arg(parser): parser.add_argument( - "-f", - "--format", - dest="format", - action="store", + u"-f", + u"--format", + dest=u"format", + action=u"store", choices=OutputFormat(), default=OutputFormat.JSON, - help="The format used for outputting events.", + help=u"The format used for outputting events.", ) def _add_incremental_arg(parser): parser.add_argument( - "-i", - "--incremental", + u"-i", + u"--incremental", dest=IS_INCREMENTAL_KEY, - action="store_true", - help="Only get events that were not previously retrieved.", + action=u"store_true", + help=u"Only get events that were not previously retrieved.", ) -def _add_debug_args(parser): +def _add_debug_arg(parser): parser.add_argument( - "-d", "--debug", dest="is_debug_mode", action="store_true", help="Turn on Debug logging." + u"-d", + u"--debug", + dest=u"is_debug_mode", + action=u"store_true", + help=u"Turn on Debug logging.", ) diff --git a/src/code42cli/securitydata/arguments/search.py b/src/code42cli/securitydata/arguments/search.py index 08201ac42..c686bac18 100644 --- a/src/code42cli/securitydata/arguments/search.py +++ b/src/code42cli/securitydata/arguments/search.py @@ -6,60 +6,188 @@ def add_arguments_to_parser(parser): _add_begin_date_arg(parser) _add_end_date_arg(parser) _add_exposure_types_arg(parser) + _add_username_arg(parser) + _add_actor_arg(parser) + _add_md5_arg(parser) + _add_sha256_arg(parser) + _add_source_arg(parser) + _add_filename_arg(parser) + _add_filepath_arg(parser) + _add_process_owner_arg(parser) + _add_tab_url_arg(parser) + _add_include_non_exposure_arg(parser) class SearchArguments(object): - ADVANCED_QUERY = "advanced_query" - BEGIN_DATE = "begin_date" - END_DATE = "end_date" - EXPOSURE_TYPES = "exposure_types" + ADVANCED_QUERY = u"advanced_query" + BEGIN_DATE = u"begin_date" + END_DATE = u"end_date" + EXPOSURE_TYPES = u"exposure_types" + C42USERNAME = u"c42username" + ACTOR = u"actor" + MD5 = u"md5" + SHA256 = u"sha256" + SOURCE = u"source" + FILENAME = u"filename" + FILEPATH = u"filepath" + PROCESS_OWNER = u"process_owner" + TAB_URL = u"tab_url" + INCLUDE_NON_EXPOSURE_EVENTS = u"include_non_exposure_events" def __iter__(self): - return iter([self.ADVANCED_QUERY, self.BEGIN_DATE, self.END_DATE, self.EXPOSURE_TYPES]) + return iter( + [ + self.ADVANCED_QUERY, + self.BEGIN_DATE, + self.END_DATE, + self.EXPOSURE_TYPES, + self.C42USERNAME, + self.ACTOR, + self.MD5, + self.SHA256, + self.SOURCE, + self.FILENAME, + self.FILEPATH, + self.PROCESS_OWNER, + self.TAB_URL, + self.INCLUDE_NON_EXPOSURE_EVENTS, + ] + ) def _add_advanced_query(parser): parser.add_argument( - "--advanced-query", - action="store", + u"--advanced-query", + action=u"store", dest=SearchArguments.ADVANCED_QUERY, - help="A raw JSON file event query. " - "Useful for when the provided query parameters do not satisfy your requirements." - "WARNING: Using advanced queries ignores all other query parameters.", + help=u"A raw JSON file event query. " + u"Useful for when the provided query parameters do not satisfy your requirements." + u"WARNING: Using advanced queries ignores all other query parameters.", ) def _add_begin_date_arg(parser): parser.add_argument( - "-b", - "--begin", - nargs="+", - action="store", + u"-b", + u"--begin", + nargs=u"+", + action=u"store", dest=SearchArguments.BEGIN_DATE, - help="The beginning of the date range in which to look for events, " - "in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", + help=u"The beginning of the date range in which to look for events, " + u"in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", ) def _add_end_date_arg(parser): parser.add_argument( - "-e", - "--end", - nargs="+", - action="store", + u"-e", + u"--end", + nargs=u"+", + action=u"store", dest=SearchArguments.END_DATE, - help="The end of the date range in which to look for events, " - "in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", + help=u"The end of the date range in which to look for events, " + u"in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", ) def _add_exposure_types_arg(parser): parser.add_argument( - "-t", - "--types", - nargs="+", - action="store", + u"-t", + u"--types", + nargs=u"+", + action=u"store", dest=SearchArguments.EXPOSURE_TYPES, - help="Limits extracted events to those with given exposure types. " - "Available choices={0}".format(list(ExposureType())), + help=u"Limits events to those with given exposure types. " + u"Available choices={0}".format(list(ExposureType())), + ) + + +def _add_username_arg(parser): + parser.add_argument( + u"--c42username", + action=u"store", + dest=SearchArguments.C42USERNAME, + help=u"Limits events to endpoint events for this user.", + ) + + +def _add_actor_arg(parser): + parser.add_argument( + u"--actor", + action=u"store", + dest=SearchArguments.ACTOR, + help=u"Limits events to only those enacted by this actor.", + ) + + +def _add_md5_arg(parser): + parser.add_argument( + u"--md5", + action=u"store", + dest=SearchArguments.MD5, + help=u"Limits events to file events where the file has this MD5 hash.", + ) + + +def _add_sha256_arg(parser): + parser.add_argument( + u"--sha256", + action=u"store", + dest=SearchArguments.SHA256, + help=u"Limits events to file events where the file has this SHA256 hash.", + ) + + +def _add_source_arg(parser): + parser.add_argument( + u"--source", + action=u"store", + dest=SearchArguments.SOURCE, + help=u"Limits events to only those from this source. Example=Gmail.", + ) + + +def _add_filename_arg(parser): + parser.add_argument( + u"--filename", + action=u"store", + dest=SearchArguments.FILENAME, + help=u"Limits events to file events where the file has this name.", + ) + + +def _add_filepath_arg(parser): + parser.add_argument( + u"--filepath", + action=u"store", + dest=SearchArguments.FILEPATH, + help=u"Limits events to file events where the file is located at this path.", + ) + + +def _add_process_owner_arg(parser): + parser.add_argument( + u"--processOwner", + action=u"store", + dest=SearchArguments.PROCESS_OWNER, + help=u"Limits events to exposure events where this user " + u"owns the process behind the exposure.", + ) + + +def _add_tab_url_arg(parser): + parser.add_argument( + u"--tabURL", + action=u"store", + dest=SearchArguments.TAB_URL, + help=u"Limits events to be exposure events with this destination tab URL.", + ) + + +def _add_include_non_exposure_arg(parser): + parser.add_argument( + u"--include-non-exposure", + action=u"store_true", + dest=SearchArguments.INCLUDE_NON_EXPOSURE_EVENTS, + help=u"Get all events including non-exposure events.", ) diff --git a/src/code42cli/securitydata/cursor_store.py b/src/code42cli/securitydata/cursor_store.py index 2944cdcc6..753f9942c 100644 --- a/src/code42cli/securitydata/cursor_store.py +++ b/src/code42cli/securitydata/cursor_store.py @@ -3,24 +3,22 @@ from code42cli.util import get_user_project_path -_INSERTION_TIMESTAMP_FIELD_NAME = "insertionTimestamp" +_INSERTION_TIMESTAMP_FIELD_NAME = u"insertionTimestamp" class SecurityEventCursorStore(object): - _PRIMARY_KEY_COLUMN_NAME = "cursor_id" + _PRIMARY_KEY_COLUMN_NAME = u"cursor_id" def __init__(self, db_table_name, db_file_path=None): - # type: (str, str) -> None self._table_name = db_table_name if db_file_path is None: - db_path = get_user_project_path("db") - db_file_path = "{0}/{1}.db".format(db_path, self._table_name) + db_path = get_user_project_path(u"db") + db_file_path = u"{0}/{1}.db".format(db_path, self._table_name) self._connection = sqlite3.connect(db_file_path) def _get(self, columns, primary_key): - # type: (str, any) -> list - query = "SELECT {0} FROM {1} WHERE {2}=?" + query = u"SELECT {0} FROM {1} WHERE {2}=?" query = query.format(columns, self._table_name, self._PRIMARY_KEY_COLUMN_NAME) with self._connection as conn: cursor = conn.cursor() @@ -28,20 +26,19 @@ def _get(self, columns, primary_key): return cursor.fetchall() def _set(self, column_name, new_value, primary_key): - # type: (str, any, any) -> None - query = "UPDATE {0} SET {1}=? WHERE {2}=?".format( + query = u"UPDATE {0} SET {1}=? WHERE {2}=?".format( self._table_name, column_name, self._PRIMARY_KEY_COLUMN_NAME ) with self._connection as conn: conn.execute(query, (new_value, primary_key)) def _drop_table(self): - drop_query = "DROP TABLE {0}".format(self._table_name) + drop_query = u"DROP TABLE {0}".format(self._table_name) with self._connection as conn: conn.execute(drop_query) def _is_empty(self): - table_count_query = """ + table_count_query = u""" SELECT COUNT(name) FROM sqlite_master WHERE type='table' AND name=? @@ -58,16 +55,18 @@ class AEDCursorStore(SecurityEventCursorStore): _PRIMARY_KEY = 1 def __init__(self, db_file_path=None): - super(AEDCursorStore, self).__init__("aed_checkpoint", db_file_path) + super(AEDCursorStore, self).__init__(u"aed_checkpoint", db_file_path) if self._is_empty(): self._init_table() def get_stored_insertion_timestamp(self): + """Gets the last stored insertion timestamp.""" rows = self._get(_INSERTION_TIMESTAMP_FIELD_NAME, self._PRIMARY_KEY) if rows and rows[0]: return rows[0][0] def replace_stored_insertion_timestamp(self, new_insertion_timestamp): + """Replaces the last stored insertion timestamp with the given one.""" self._set( column_name=_INSERTION_TIMESTAMP_FIELD_NAME, new_value=new_insertion_timestamp, @@ -79,9 +78,9 @@ def reset(self): self._init_table() def _init_table(self): - columns = "{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, _INSERTION_TIMESTAMP_FIELD_NAME) - create_table_query = "CREATE TABLE {0} ({1})".format(self._table_name, columns) - insert_query = "INSERT INTO {0} VALUES(?, null)".format(self._table_name) + columns = u"{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, _INSERTION_TIMESTAMP_FIELD_NAME) + create_table_query = u"CREATE TABLE {0} ({1})".format(self._table_name, columns) + insert_query = u"INSERT INTO {0} VALUES(?, null)".format(self._table_name) with self._connection as conn: conn.execute(create_table_query) conn.execute(insert_query, (self._PRIMARY_KEY,)) diff --git a/src/code42cli/date_helper.py b/src/code42cli/securitydata/date_helper.py similarity index 73% rename from src/code42cli/date_helper.py rename to src/code42cli/securitydata/date_helper.py index c32ed9596..ebd036d36 100644 --- a/src/code42cli/date_helper.py +++ b/src/code42cli/securitydata/date_helper.py @@ -7,16 +7,30 @@ _FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format." -def create_event_timestamp_range(begin_date=None, end_date=None): +def create_event_timestamp_range(begin_date, end_date=None): + """Creates a `py42.sdk.file_event_query.event_query.EventTimestamp.in_range` filter + using the provided dates. If begin_date is None, it uses a date that is 60 days back. + If end_date is None, it uses the current UTC time. + + Args: + begin_date: The begin date for the range. If None, defaults to 60 days back from the current UTC time. + end_date: The end date for the range. If None, defaults to the current time. + + """ + end_date = _get_end_date_with_eod_time_if_needed(end_date) min_timestamp = _parse_min_timestamp(begin_date) max_timestamp = _parse_max_timestamp(end_date) _verify_timestamp_order(min_timestamp, max_timestamp) return EventTimestamp.in_range(min_timestamp, max_timestamp) +def _get_end_date_with_eod_time_if_needed(end_date): + if end_date and len(end_date) == 1: + return end_date[0], "23:59:59" + return end_date + + def _parse_min_timestamp(begin_date_str): - if not begin_date_str: - return _get_default_min_timestamp() min_timestamp = _parse_timestamp(begin_date_str) boundary_date = datetime.utcnow() - timedelta(days=_MAX_LOOK_BACK_DAYS) boundary = convert_datetime_to_timestamp(boundary_date) @@ -61,12 +75,5 @@ def _join_date_tuple(date_tuple): return date_str -def _get_default_min_timestamp(): - now = datetime.utcnow() - start_day = timedelta(days=_DEFAULT_LOOK_BACK_DAYS) - days_ago = now - start_day - return convert_datetime_to_timestamp(days_ago) - - def _get_default_max_timestamp(): return convert_datetime_to_timestamp(datetime.utcnow()) diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index a690224c0..80c23435a 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -5,19 +5,34 @@ from py42 import settings from c42eventextractor import FileEventHandlers from c42eventextractor.extractors import FileEventExtractor +from py42.sdk.file_event_query.cloud_query import Actor +from py42.sdk.file_event_query.device_query import DeviceUsername +from py42.sdk.file_event_query.event_query import Source +from py42.sdk.file_event_query.exposure_query import ExposureType, ProcessOwner, TabURL +from py42.sdk.file_event_query.file_query import MD5, SHA256, FileName, FilePath from code42cli.compat import str -from code42cli.securitydata.options import ExposureType -from code42cli.util import print_error -from code42cli import date_helper as date_helper +from code42cli.util import print_error, print_bold +from code42cli.profile.profile import get_profile +from code42cli.securitydata.options import ExposureType as ExposureTypeOptions +from code42cli.securitydata import date_helper as date_helper from code42cli.securitydata.cursor_store import AEDCursorStore from code42cli.securitydata.logger_factory import get_error_logger -from code42cli.profile.profile import get_profile from code42cli.securitydata.arguments.search import SearchArguments from code42cli.securitydata.arguments.main import IS_INCREMENTAL_KEY def extract(output_logger, args): + """Extracts file events using the given command-line arguments. + + Args: + output_logger: The logger specified by which subcommand you use. For example, + print: uses a logger that streams to stdout. + write-to: uses a logger that logs to a file. + send-to: uses a logger that sends logs to a server. + args: + Command line args used to build up file event query filters. + """ handlers = _create_event_handlers(output_logger, args.is_incremental) profile = get_profile() sdk = _get_sdk(profile, args.is_debug_mode) @@ -45,19 +60,26 @@ def handle_response(response): def _get_sdk(profile, is_debug_mode): - code42 = SDK.create_using_local_account( - profile.authority_url, profile.username, profile.get_password() - ) if is_debug_mode: settings.debug_level = debug_level.DEBUG - return code42 + try: + return SDK.create_using_local_account( + profile.authority_url, profile.username, profile.get_password() + ) + except: + print_error( + u"Invalid credentials or host address. " + u"Verify your profile is set up correctly and that you are supplying the correct password." + ) + exit(1) def _call_extract(extractor, args): if not _determine_if_advanced_query(args): - event_timestamp_filter_group = _get_event_timestamp_filter(args) + _verify_begin_date(args.begin_date) _verify_exposure_types(args.exposure_types) - extractor.extract(args.exposure_types, event_timestamp_filter_group) + filters = _create_filters(args) + extractor.extract(*filters) else: extractor.extract_advanced(args.advanced_query) @@ -90,11 +112,51 @@ def _get_event_timestamp_filter(args): exit(1) +def _verify_begin_date(begin_date): + if not begin_date: + print_error(u"'begin date' is required.") + print(u"") + print(u"Try using '-b' or '--begin'. Use `-h` for more info.") + print(u"") + exit(1) + + def _verify_exposure_types(exposure_types): if exposure_types is None: return - options = list(ExposureType()) + options = list(ExposureTypeOptions()) for exposure_type in exposure_types: if exposure_type not in options: print_error(u"'{0}' is not a valid exposure type.".format(exposure_type)) exit(1) + + +def _create_filters(args): + filters = [_get_event_timestamp_filter(args)] + not args.c42username or filters.append(DeviceUsername.eq(args.c42username)) + not args.actor or filters.append(Actor.eq(args.actor)) + not args.md5 or filters.append(MD5.eq(args.md5)) + not args.sha256 or filters.append(SHA256.eq(args.sha256)) + not args.source or filters.append(Source.eq(args.source)) + not args.filename or filters.append(FileName.eq(args.filename)) + not args.filepath or filters.append(FilePath.eq(args.filepath)) + not args.process_owner or filters.append(ProcessOwner.eq(args.process_owner)) + not args.tab_url or filters.append(TabURL.eq(args.tab_url)) + _try_append_exposure_types_filter(filters, args) + return filters + + +def _try_append_exposure_types_filter(filters, args): + exposure_filter = _create_exposure_type_filter(args) + if exposure_filter: + filters.append(exposure_filter) + + +def _create_exposure_type_filter(args): + if args.include_non_exposure_events and args.exposure_types: + print_error(u"Cannot use exposure types with `--include-non-exposure`.") + exit(1) + if args.exposure_types: + return ExposureType.is_in(args.exposure_types) + if not args.include_non_exposure_events: + return ExposureType.exists() diff --git a/src/code42cli/securitydata/logger_factory.py b/src/code42cli/securitydata/logger_factory.py index 49494a6a4..1af5be236 100644 --- a/src/code42cli/securitydata/logger_factory.py +++ b/src/code42cli/securitydata/logger_factory.py @@ -1,5 +1,7 @@ import sys import logging +from threading import Lock + from logging.handlers import RotatingFileHandler from c42eventextractor.logging.formatters import ( FileEventDictToJSONFormatter, @@ -12,44 +14,77 @@ from code42cli.securitydata.options import OutputFormat from code42cli.util import get_user_project_path, print_error +_logger_deps_lock = Lock() + def get_logger_for_stdout(output_format): - logger = logging.getLogger("code42_stdout_{0}".format(output_format.lower())) + """Gets the stdout logger for the given format. + + Args: + output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. + """ + logger = logging.getLogger(u"code42_stdout_{0}".format(output_format.lower())) if _logger_has_handlers(logger): return logger - handler = logging.StreamHandler(sys.stdout) - return _init_logger(logger, handler, output_format) + with _logger_deps_lock: + if not _logger_has_handlers(logger): + handler = logging.StreamHandler(sys.stdout) + return _init_logger(logger, handler, output_format) + return logger def get_logger_for_file(filename, output_format): - logger = logging.getLogger("code42_file_{0}".format(output_format.lower())) + """Gets the logger that logs to a file for the given format. + + Args: + filename: The name of the file to write logs to. + output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. + """ + logger = logging.getLogger(u"code42_file_{0}".format(output_format.lower())) if _logger_has_handlers(logger): return logger - handler = logging.FileHandler(filename, delay=True) - return _init_logger(logger, handler, output_format) + with _logger_deps_lock: + if not _logger_has_handlers(logger): + handler = logging.FileHandler(filename, delay=True) + return _init_logger(logger, handler, output_format) + return logger def get_logger_for_server(hostname, protocol, output_format): - logger = logging.getLogger("code42_syslog_{0}".format(output_format.lower())) + """Gets the logger that sends logs to a server for the given format. + + Args: + hostname: The hostname of the server. + protocol: The transfer protocol for sending logs. + output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. + """ + logger = logging.getLogger(u"code42_syslog_{0}".format(output_format.lower())) if _logger_has_handlers(logger): return logger - handler = NoPrioritySysLogHandlerWrapper(hostname, protocol=protocol).handler - return _init_logger(logger, handler, output_format) + with _logger_deps_lock: + if not _logger_has_handlers(logger): + handler = NoPrioritySysLogHandlerWrapper(hostname, protocol=protocol).handler + return _init_logger(logger, handler, output_format) + return logger def get_error_logger(): - log_path = get_user_project_path("log") - log_path = "{0}/code42_errors.log".format(log_path) - logger = logging.getLogger("code42_error_logger") + """Gets the logger where exceptions are logged.""" + log_path = get_user_project_path(u"log") + log_path = u"{0}/code42_errors.log".format(log_path) + logger = logging.getLogger(u"code42_error_logger") if _logger_has_handlers(logger): return logger - formatter = logging.Formatter("%(asctime)s %(message)s") - handler = RotatingFileHandler(log_path, maxBytes=250000000) - return _apply_logger_dependencies(logger, handler, formatter) + with _logger_deps_lock: + if not _logger_has_handlers(logger): + formatter = logging.Formatter(u"%(asctime)s %(message)s") + handler = RotatingFileHandler(log_path, maxBytes=250000000) + return _apply_logger_dependencies(logger, handler, formatter) + return logger def _logger_has_handlers(logger): diff --git a/src/code42cli/securitydata/subcommands/print_out.py b/src/code42cli/securitydata/subcommands/print_out.py index f9c8614ec..a72d68acd 100644 --- a/src/code42cli/securitydata/subcommands/print_out.py +++ b/src/code42cli/securitydata/subcommands/print_out.py @@ -10,7 +10,7 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser = subcommand_parser.add_parser("print") + parser = subcommand_parser.add_parser(u"print") parser.set_defaults(func=print_out) search_args.add_arguments_to_parser(parser) main_args.add_arguments_to_parser(parser) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 29df0bbd2..229fc44c6 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -5,6 +5,7 @@ def get_input(prompt): """Uses correct input function based on Python version.""" + # pylint: disable=undefined-variable if sys.version_info >= (3, 0): return input(prompt) else: diff --git a/tests/conftest.py b/tests/conftest.py index 2bbfd1eb1..45a405667 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,6 +39,16 @@ def namespace(mocker): mock.begin_date = None mock.end_date = None mock.exposure_types = None + mock.c42username = None + mock.actor = None + mock.md5 = None + mock.sha256 = None + mock.source = None + mock.filename = None + mock.filepath = None + mock.process_owner = None + mock.tab_url = None + mock.include_non_exposure_events = None return mock @@ -51,21 +61,12 @@ class ConfigParserMocks(object): sections = None -def get_first_filter_value_from_json(json): - return json_module.loads(str(json))["filters"][0]["value"] +def get_filter_value_from_json(json, filter_index): + return json_module.loads(str(json))["filters"][filter_index]["value"] -def get_second_filter_value_from_json(json): - return json_module.loads(str(json))["filters"][1]["value"] - - -def parse_date_from_first_filter_value(json): - date_str = get_first_filter_value_from_json(json) - return convert_str_to_date(date_str) - - -def parse_date_from_second_filter_value(json): - date_str = get_second_filter_value_from_json(json) +def parse_date_from_filter_value(json, filter_index): + date_str = get_filter_value_from_json(json, filter_index) return convert_str_to_date(date_str) diff --git a/tests/profile/conftest.py b/tests/profile/conftest.py new file mode 100644 index 000000000..03503b13d --- /dev/null +++ b/tests/profile/conftest.py @@ -0,0 +1,3 @@ +PROFILE_NAMESPACE = "code42cli.profile" +CONFIG_NAMESPACE = "{0}.config".format(PROFILE_NAMESPACE) +PASSWORD_NAMESPACE = "{0}.password".format(PROFILE_NAMESPACE) diff --git a/tests/profile/test_config.py b/tests/profile/test_config.py index 9c81c9a89..9500856c6 100644 --- a/tests/profile/test_config.py +++ b/tests/profile/test_config.py @@ -13,6 +13,11 @@ class SharedConfigMocks(object): def setup_existing_config_file(self): self.path_exists_function.return_value = True + sections = self.mocker.patch("configparser.ConfigParser.sections") + sections.return_value = [ + config.ConfigurationKeys.INTERNAL_SECTION, + config.ConfigurationKeys.USER_SECTION, + ] def setup_non_existing_config_file(self): self.path_exists_function.return_value = False @@ -56,9 +61,12 @@ def shared_config_mocks(mocker, config_parser): return mocks -def assert_save_was_called(open_file_function): +def save_was_called(open_file_function): call_args = open_file_function.call_args - assert call_args[0][0] == "some/path/config.cfg" and call_args[0][1] == "w+" + try: + return call_args[0][0] == "some/path/config.cfg" and call_args[0][1] == "w+" + except: + return False def test_get_config_profile_when_file_exists_but_profile_does_not_exist_exits(shared_config_mocks): @@ -86,13 +94,7 @@ def test_get_config_profile_when_file_does_not_exist_saves_changes(shared_config config.get_config_profile() # It saves because it is writing default values to the config file - assert_save_was_called(shared_config_mocks.open_function) - - -def test_mark_as_set_saves_changes(shared_config_mocks): - shared_config_mocks.setup_existing_profile() - config.mark_as_set() - assert_save_was_called(shared_config_mocks.open_function) + assert save_was_called(shared_config_mocks.open_function) def test_profile_has_been_set_when_is_set_returns_true(shared_config_mocks): @@ -105,16 +107,34 @@ def test_profile_has_been_set_when_is_not_set_returns_false(shared_config_mocks) assert not config.profile_has_been_set() +def test_mark_as_set_if_complete_when_profile_is_set_but_not_marked_in_config_file_saves( + shared_config_mocks +): + shared_config_mocks.setup_existing_profile() + shared_config_mocks.setup_non_existing_config_file() + config.mark_as_set_if_complete() + assert save_was_called(shared_config_mocks.open_function) + + +def test_mark_as_set_if_complete_when_already_set_and_marked_in_config_file_does_not_save( + shared_config_mocks +): + shared_config_mocks.setup_existing_profile() + shared_config_mocks.setup_existing_config_file() + config.mark_as_set_if_complete() + assert not save_was_called(shared_config_mocks.open_function) + + def test_set_username_saves(shared_config_mocks): config.set_username("New user") - assert_save_was_called(shared_config_mocks.open_function) + assert save_was_called(shared_config_mocks.open_function) def test_set_authority_url_saves(shared_config_mocks): config.set_authority_url("New url") - assert_save_was_called(shared_config_mocks.open_function) + assert save_was_called(shared_config_mocks.open_function) def test_set_ignore_ssl_errors_saves(shared_config_mocks): config.set_ignore_ssl_errors(True) - assert_save_was_called(shared_config_mocks.open_function) + assert save_was_called(shared_config_mocks.open_function) diff --git a/tests/profile/test_password.py b/tests/profile/test_password.py index afb158566..00a53660c 100644 --- a/tests/profile/test_password.py +++ b/tests/profile/test_password.py @@ -22,40 +22,23 @@ def test_get_password_uses_expected_service_name_and_username( keyring_password_getter, config_profile ): password.get_password() - # See conftest.config_profile expected_service_name = "code42cli::https://authority.example.com" expected_username = "test.username" keyring_password_getter.assert_called_once_with(expected_service_name, expected_username) -def test_get_password_when_password_is_none_returns_password_from_getpass( - keyring_password_getter, config_profile, getpass_function -): - keyring_password_getter.return_value = None - getpass_function.return_value = "test password" - assert password.get_password() == "test password" - - -def test_get_password_when_password_is_not_none_returns_password( +def test_get_password_returns_expected_password( keyring_password_getter, config_profile, keyring_password_setter ): keyring_password_getter.return_value = "already stored password 123" assert password.get_password() == "already stored password 123" -def test_get_password_when_password_is_none_and_told_to_not_prompt_if_not_exists_returns_none( - keyring_password_getter, config_profile -): - keyring_password_getter.return_value = None - assert password.get_password(prompt_if_not_exists=False) is None - - -def test_set_password_uses_expected_service_name_username_and_password( +def test_set_password_from_prompt_uses_expected_service_name_username_and_password( keyring_password_setter, config_profile, getpass_function ): getpass_function.return_value = "test password" - password.set_password() - # See conftest.config_profile + password.set_password_from_prompt() expected_service_name = "code42cli::https://authority.example.com" expected_username = "test.username" keyring_password_setter.assert_called_once_with( diff --git a/tests/profile/test_profile.py b/tests/profile/test_profile.py index dc17fa1be..9c618a6f5 100644 --- a/tests/profile/test_profile.py +++ b/tests/profile/test_profile.py @@ -1,50 +1,62 @@ import pytest from argparse import ArgumentParser from code42cli.profile import profile +from .conftest import CONFIG_NAMESPACE, PASSWORD_NAMESPACE, PROFILE_NAMESPACE -@pytest.fixture +@pytest.fixture(autouse=True) def username_setter(mocker): - return mocker.patch("code42cli.profile.config.set_username") + return mocker.patch("{0}.set_username".format(CONFIG_NAMESPACE)) -@pytest.fixture +@pytest.fixture(autouse=True) def mark_as_set_function(mocker): - return mocker.patch("code42cli.profile.config.mark_as_set") + return mocker.patch("{0}.mark_as_set_if_complete".format(CONFIG_NAMESPACE)) -@pytest.fixture +@pytest.fixture(autouse=True) def authority_url_setter(mocker): - return mocker.patch("code42cli.profile.config.set_authority_url") + return mocker.patch("{0}.set_authority_url".format(CONFIG_NAMESPACE)) -@pytest.fixture +@pytest.fixture(autouse=True) def ignore_ssl_errors_setter(mocker): - return mocker.patch("code42cli.profile.config.set_ignore_ssl_errors") + return mocker.patch("{0}.set_ignore_ssl_errors".format(CONFIG_NAMESPACE)) -@pytest.fixture +@pytest.fixture(autouse=True) def password_setter(mocker): - return mocker.patch("code42cli.profile.password.set_password") + return mocker.patch("{0}.set_password_from_prompt".format(PASSWORD_NAMESPACE)) -@pytest.fixture +@pytest.fixture(autouse=True) def password_getter(mocker): - return mocker.patch("code42cli.profile.password.get_password") + return mocker.patch("{0}.get_password".format(PASSWORD_NAMESPACE)) -@pytest.fixture -def profile_not_set_state(mocker): - profile_verifier = mocker.patch("code42cli.profile.config.profile_has_been_set") - profile_verifier.return_value = False - return profile_verifier +@pytest.fixture(autouse=True) +def input_function(mocker): + return mocker.patch("{0}.profile.get_input".format(PROFILE_NAMESPACE)) -@pytest.fixture -def profile_is_set_state(mocker): - profile_verifier = mocker.patch("code42cli.profile.config.profile_has_been_set") - profile_verifier.return_value = True - return profile_verifier +def _get_profile_parser(): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + return subcommand_parser.choices.get("profile") + + +class TestCode42Profile(object): + def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter): + password_getter.return_value = None + mock_getpass = mocker.patch("{0}.get_password_from_prompt".format(PASSWORD_NAMESPACE)) + mock_getpass.return_value = "Test Password" + actual = profile.Code42Profile().get_password() + assert actual == "Test Password" + + def test_get_password_return_password_from_password_get_password(self, password_getter): + password_getter.return_value = "Test Password" + actual = profile.Code42Profile().get_password() + assert actual == "Test Password" def test_init_adds_profile_subcommand_to_choices(config_parser): @@ -65,13 +77,12 @@ def test_init_adds_parser_that_can_parse_set_command(config_parser): profile.init(subcommand_parser) profile_parser = subcommand_parser.choices.get("profile") profile_parser.parse_args( - ["set", "-s", "server-arg", "-p", "-u", "username-arg", "--enable-ssl-errors"] + ["set", "-s", "server-arg", "-u", "username-arg", "--enable-ssl-errors"] ) def test_get_profile_returns_object_from_config_profile(config_parser, config_profile): user = profile.get_profile() - # Values from config_profile fixture assert ( user.username == "test.username" @@ -80,9 +91,7 @@ def test_get_profile_returns_object_from_config_profile(config_parser, config_pr ) -def test_set_profile_when_given_username_sets_username( - config_parser, username_setter, password_getter, profile_is_set_state -): +def test_set_profile_when_given_username_sets_username(config_parser, username_setter): parser = _get_profile_parser() namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) profile.set_profile(namespace) @@ -90,7 +99,7 @@ def test_set_profile_when_given_username_sets_username( def test_set_profile_when_given_authority_url_sets_authority_url( - config_parser, authority_url_setter, profile_is_set_state, password_getter + config_parser, authority_url_setter ): parser = _get_profile_parser() namespace = parser.parse_args(["set", "-s", "https://wwww.new.authority.example.com"]) @@ -99,7 +108,7 @@ def test_set_profile_when_given_authority_url_sets_authority_url( def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true( - config_parser, ignore_ssl_errors_setter, profile_is_set_state, password_getter + config_parser, ignore_ssl_errors_setter ): parser = _get_profile_parser() namespace = parser.parse_args(["set", "--enable-ssl-errors"]) @@ -108,7 +117,7 @@ def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_false( - config_parser, ignore_ssl_errors_setter, profile_is_set_state, password_getter + config_parser, ignore_ssl_errors_setter ): parser = _get_profile_parser() namespace = parser.parse_args(["set", "--disable-ssl-errors"]) @@ -116,104 +125,55 @@ def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_fal ignore_ssl_errors_setter.assert_called_once_with(True) -def test_set_profile_when_is_first_time_and_given_username_but_not_given_authority_url_fails( - username_setter, profile_not_set_state -): - parser = _get_profile_parser() - namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) - with pytest.raises(SystemExit): - profile.set_profile(namespace) - - -def test_set_profile_when_is_first_time_and_given_username_but_not_given_authority_url_does_not_set( - config_parser, username_setter, profile_not_set_state -): - parser = _get_profile_parser() - namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) - with pytest.raises(SystemExit): - profile.set_profile(namespace) - - assert username_setter.call_args is None - - -def test_set_profile_when_is_first_time_and_given_authority_url_but_not_given_username_fails( - config_parser, authority_url_setter, profile_not_set_state -): - parser = _get_profile_parser() - namespace = parser.parse_args(["set", "-s", "https://wwww.new.authority.example.com"]) - with pytest.raises(SystemExit): - profile.set_profile(namespace) - - -def test_set_profile_when_is_first_time_and_given_authority_url_but_not_given_username_does_not_set( - config_parser, authority_url_setter, profile_not_set_state -): - parser = _get_profile_parser() - namespace = parser.parse_args(["set", "-s", "https://wwww.new.authority.example.com"]) - with pytest.raises(SystemExit): - profile.set_profile(namespace) - - assert authority_url_setter.call_args is None - - -def test_set_profile_when_is_first_time_and_given_both_authority_and_username_sets_username( - config_parser, - profile_not_set_state, - username_setter, - authority_url_setter, - password_getter, - mark_as_set_function, -): +def test_set_profile_calls_marks_as_set_if_complete(config_parser, mark_as_set_function): parser = _get_profile_parser() namespace = parser.parse_args( ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] ) profile.set_profile(namespace) - username_setter.assert_called_once_with("user") + assert mark_as_set_function.call_count -def test_set_profile_when_is_first_time_and_given_both_authority_and_username_sets_authority_url( - config_parser, - profile_not_set_state, - username_setter, - authority_url_setter, - password_getter, - mark_as_set_function, +def test_set_profile_when_told_to_store_password_prompts_for_storing_password( + mocker, input_function ): + input_function.return_value = "y" + mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") parser = _get_profile_parser() namespace = parser.parse_args( ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] ) profile.set_profile(namespace) - authority_url_setter.assert_called_once_with("https://wwww.new.authority.example.com") + assert mock_set_password_function.call_count -def test_set_profile_when_is_first_time_and_given_both_authority_and_username_marks_as_set( - config_parser, - profile_not_set_state, - username_setter, - authority_url_setter, - password_getter, - mark_as_set_function, +def test_set_profile_when_told_to_store_password_using_capital_y_prompts_for_storing_password( + mocker, input_function ): + input_function.return_value = "Y" + mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") parser = _get_profile_parser() namespace = parser.parse_args( ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] ) profile.set_profile(namespace) - mark_as_set_function.assert_called_once_with() + assert mock_set_password_function.call_count -def test_set_profile_when_given_password_sets_password( - config_parser, password_setter, password_getter, profile_is_set_state +def test_set_profile_when_told_not_to_store_password_prompts_for_storing_password( + mocker, input_function ): + input_function.return_value = "n" + mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") parser = _get_profile_parser() - namespace = parser.parse_args(["set", "-p"]) + namespace = parser.parse_args( + ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] + ) profile.set_profile(namespace) - assert len(password_setter.call_args) > 0 + assert not mock_set_password_function.call_count -def _get_profile_parser(): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - return subcommand_parser.choices.get("profile") +def test_prompt_for_password_reset_calls_password_set_password_from_prompt(mocker): + mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") + profile.prompt_for_password_reset() + assert mock_set_password_function.call_count diff --git a/tests/securitydata/conftest.py b/tests/securitydata/conftest.py index 6f0ccdfa2..265404562 100644 --- a/tests/securitydata/conftest.py +++ b/tests/securitydata/conftest.py @@ -1,2 +1,10 @@ -ROOT_PATH = "code42cli.securitydata" -SUBCOMMANDS_PATH = "{0}.subcommands".format(ROOT_PATH) +from tests.conftest import get_test_date_str + +SECURITYDATA_NAMESPACE = "code42cli.securitydata" +SUBCOMMANDS_NAMESPACE = "{0}.subcommands".format(SECURITYDATA_NAMESPACE) + + +begin_date_tuple = (get_test_date_str(days_ago=89),) +begin_date_tuple_with_time = (get_test_date_str(days_ago=89), "3:12:33") +end_date_tuple = (get_test_date_str(days_ago=10),) +end_date_tuple_with_time = (get_test_date_str(days_ago=10), "11:22:43") diff --git a/tests/securitydata/subcommands/conftest.py b/tests/securitydata/subcommands/conftest.py new file mode 100644 index 000000000..4f6907cbd --- /dev/null +++ b/tests/securitydata/subcommands/conftest.py @@ -0,0 +1,34 @@ +ACCEPTABLE_ARGS = [ + "-t", + "SharedToDomain", + "ApplicationRead", + "CloudStorage", + "RemovableMedia", + "IsPublic", + "-f", + "JSON", + "-d", + "-b", + "600", + "-e", + "2020-02-02", + "--c42username", + "test.testerson", + "--actor", + "test.testerson", + "--md5", + "098f6bcd4621d373cade4e832627b4f6", + "--sha256", + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "--source", + "Gmail", + "--filename", + "file.txt", + "--filepath", + "/path/to/file.txt", + "--processOwner", + "test.testerson", + "--tabURL", + "https://example.com", + "--include-non-exposure", +] diff --git a/tests/securitydata/subcommands/test_clear_checkpoint.py b/tests/securitydata/subcommands/test_clear_checkpoint.py index 0897af279..9cf085903 100644 --- a/tests/securitydata/subcommands/test_clear_checkpoint.py +++ b/tests/securitydata/subcommands/test_clear_checkpoint.py @@ -1,10 +1,10 @@ import pytest -from tests.securitydata.conftest import ROOT_PATH +from ..conftest import SECURITYDATA_NAMESPACE from code42cli.securitydata.subcommands import clear_checkpoint as clearer -_CURSOR_STORE_PATH = "{0}.cursor_store".format(ROOT_PATH) +_CURSOR_STORE_PATH = "{0}.cursor_store".format(SECURITYDATA_NAMESPACE) @pytest.fixture diff --git a/tests/securitydata/subcommands/test_print_out.py b/tests/securitydata/subcommands/test_print_out.py index 5d5abcce5..97748051f 100644 --- a/tests/securitydata/subcommands/test_print_out.py +++ b/tests/securitydata/subcommands/test_print_out.py @@ -1,11 +1,12 @@ import pytest from argparse import ArgumentParser -from tests.securitydata.conftest import SUBCOMMANDS_PATH +from ..conftest import SUBCOMMANDS_NAMESPACE +from .conftest import ACCEPTABLE_ARGS import code42cli.securitydata.subcommands.print_out as printer -_PRINT_PATH = "{0}.print_out".format(SUBCOMMANDS_PATH) +_PRINT_PATH = "{0}.print_out".format(SUBCOMMANDS_NAMESPACE) @pytest.fixture @@ -22,23 +23,7 @@ def test_init_adds_parser_that_can_parse_supported_args(config_parser): subcommand_parser = ArgumentParser().add_subparsers() printer.init(subcommand_parser) print_parser = subcommand_parser.choices.get("print") - print_parser.parse_args( - [ - "-t", - "SharedToDomain", - "ApplicationRead", - "CloudStorage", - "RemovableMedia", - "IsPublic", - "-f", - "JSON", - "-d", - "-b", - "600", - "-e", - "2020-02-02", - ] - ) + print_parser.parse_args(ACCEPTABLE_ARGS) def test_print_out_uses_logger_for_stdout(namespace, logger_factory, extractor): diff --git a/tests/securitydata/subcommands/test_send_to.py b/tests/securitydata/subcommands/test_send_to.py index 6e5b5bc9f..7534c1ada 100644 --- a/tests/securitydata/subcommands/test_send_to.py +++ b/tests/securitydata/subcommands/test_send_to.py @@ -1,11 +1,12 @@ import pytest from argparse import ArgumentParser -from tests.securitydata.conftest import SUBCOMMANDS_PATH +from ..conftest import SUBCOMMANDS_NAMESPACE +from .conftest import ACCEPTABLE_ARGS from code42cli.securitydata.subcommands import send_to as sender -_SEND_PATH = "{0}.send_to".format(SUBCOMMANDS_PATH) +_SEND_PATH = "{0}.send_to".format(SUBCOMMANDS_NAMESPACE) @pytest.fixture @@ -30,24 +31,8 @@ def test_init_adds_parser_that_can_parse_supported_args(config_parser): subcommand_parser = ArgumentParser().add_subparsers() sender.init(subcommand_parser) send_parser = subcommand_parser.choices.get("send-to") - send_parser.parse_args( - [ - "https://www.syslog.com", - "-t", - "SharedToDomain", - "ApplicationRead", - "CloudStorage", - "RemovableMedia", - "IsPublic", - "-f", - "JSON", - "-d", - "-b", - "600", - "-e", - "2020-02-02", - ] - ) + args = ["https://www.syslog.com", "-p", "UDP"] + ACCEPTABLE_ARGS + send_parser.parse_args(args) def test_init_adds_parser_when_not_given_server_causes_system_exit(config_parser): diff --git a/tests/securitydata/subcommands/test_write_to.py b/tests/securitydata/subcommands/test_write_to.py index 2ae0e0555..a9e72c7d7 100644 --- a/tests/securitydata/subcommands/test_write_to.py +++ b/tests/securitydata/subcommands/test_write_to.py @@ -1,11 +1,12 @@ import pytest from argparse import ArgumentParser -from tests.securitydata.conftest import SUBCOMMANDS_PATH +from ..conftest import SUBCOMMANDS_NAMESPACE +from .conftest import ACCEPTABLE_ARGS from code42cli.securitydata.subcommands import write_to as writer -_WRITE_PATH = "{0}.write_to".format(SUBCOMMANDS_PATH) +_WRITE_PATH = "{0}.write_to".format(SUBCOMMANDS_NAMESPACE) @pytest.fixture @@ -29,24 +30,8 @@ def test_init_adds_parser_that_can_parse_supported_args(config_parser): subcommand_parser = ArgumentParser().add_subparsers() writer.init(subcommand_parser) write_parser = subcommand_parser.choices.get("write-to") - write_parser.parse_args( - [ - "out.txt", - "-t", - "SharedToDomain", - "ApplicationRead", - "CloudStorage", - "RemovableMedia", - "IsPublic", - "-f", - "JSON", - "-d", - "-b", - "600", - "-e", - "2020-02-02", - ] - ) + args = ["out.txt"] + ACCEPTABLE_ARGS + write_parser.parse_args(args) def test_init_adds_parser_when_not_given_filename_causes_system_exit(config_parser): diff --git a/tests/securitydata/test_date_helper.py b/tests/securitydata/test_date_helper.py new file mode 100644 index 000000000..65427bf95 --- /dev/null +++ b/tests/securitydata/test_date_helper.py @@ -0,0 +1,72 @@ +import pytest + +from code42cli.securitydata.date_helper import create_event_timestamp_range +from .conftest import ( + begin_date_tuple, + begin_date_tuple_with_time, + end_date_tuple, + end_date_tuple_with_time, +) +from ..conftest import get_filter_value_from_json, get_test_date_str + + +def test_create_event_timestamp_range_builds_expected_query(): + ts_range = create_event_timestamp_range(begin_date_tuple) + actual = get_filter_value_from_json(ts_range, filter_index=0) + expected = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) + assert actual == expected + + +def test_create_event_timestamp_range_when_given_begin_with_time_builds_expected_query(): + ts_range = create_event_timestamp_range(begin_date_tuple_with_time) + actual = get_filter_value_from_json(ts_range, filter_index=0) + expected = "{0}T0{1}.000Z".format(begin_date_tuple_with_time[0], begin_date_tuple_with_time[1]) + assert actual == expected + + +def test_create_event_timestamp_range_when_given_end_builds_expected_query(): + ts_range = create_event_timestamp_range(begin_date_tuple, end_date_tuple) + actual = get_filter_value_from_json(ts_range, filter_index=1) + expected = "{0}T23:59:59.000Z".format(end_date_tuple[0]) + assert actual == expected + + +def test_create_event_timestamp_range_when_given_end_with_time_builds_expected_query(): + ts_range = create_event_timestamp_range(begin_date_tuple, end_date_tuple_with_time) + actual = get_filter_value_from_json(ts_range, filter_index=1) + expected = "{0}T{1}.000Z".format(end_date_tuple_with_time[0], end_date_tuple_with_time[1]) + assert actual == expected + + +def test_create_event_timestamp_range_when_given_both_begin_and_end_builds_expected_query(): + ts_range = create_event_timestamp_range(begin_date_tuple, end_date_tuple_with_time) + actual_begin = get_filter_value_from_json(ts_range, filter_index=0) + actual_end = get_filter_value_from_json(ts_range, filter_index=1) + expected_begin = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) + expected_end = "{0}T{1}.000Z".format(end_date_tuple_with_time[0], end_date_tuple_with_time[1]) + assert actual_begin == expected_begin + assert actual_end == expected_end + + +def test_create_event_timestamp_range_when_begin_more_than_ninety_days_back_causes_value_error(): + begin_date_tuple = (get_test_date_str(days_ago=91),) + with pytest.raises(ValueError): + create_event_timestamp_range(begin_date_tuple) + + +def test_create_event_timestamp_when_end_is_before_begin_causes_value_error(): + begin_date_tuple = (get_test_date_str(days_ago=5),) + end_date_str = (get_test_date_str(days_ago=7),) + with pytest.raises(ValueError): + create_event_timestamp_range(begin_date_tuple, end_date_str) + + +def test_create_event_timestamp_when_given_minutes_ago_and_time_raises_value_error(): + with pytest.raises(ValueError): + create_event_timestamp_range("600", "12:00:00") + + +def test_create_event_timestamp_when_given_three_date_args_raises_value_error(): + begin_date_tuple = (get_test_date_str(days_ago=5), "12:00:00", "end_date=12:00:00") + with pytest.raises(ValueError): + create_event_timestamp_range(begin_date_tuple) diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index 5396b0a36..67e12f81f 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -1,17 +1,15 @@ import pytest -from datetime import datetime -from code42cli.securitydata.options import ExposureType +from py42.sdk.alert_query import Actor +from py42.sdk.file_event_query.device_query import DeviceUsername +from py42.sdk.file_event_query.event_query import Source +from py42.sdk.file_event_query.exposure_query import ExposureType, ProcessOwner, TabURL +from py42.sdk.file_event_query.file_query import FilePath, FileName, SHA256, MD5 + +from code42cli.securitydata.options import ExposureType as ExposureTypeOptions from code42cli.securitydata.extraction import extract -from .conftest import ROOT_PATH -from ..conftest import ( - get_first_filter_value_from_json, - get_second_filter_value_from_json, - parse_date_from_first_filter_value, - parse_date_from_second_filter_value, - get_test_date, - get_test_date_str, -) +from .conftest import SECURITYDATA_NAMESPACE, begin_date_tuple +from ..conftest import get_filter_value_from_json, get_test_date_str @pytest.fixture(autouse=True) @@ -29,7 +27,7 @@ def logger(mocker): @pytest.fixture(autouse=True) def error_logger(mocker): - return mocker.patch("{0}.logger_factory".format(ROOT_PATH)) + return mocker.patch("{0}.logger_factory".format(SECURITYDATA_NAMESPACE)) @pytest.fixture(autouse=True) @@ -47,6 +45,12 @@ def profile(mocker): mocker.patch("code42cli.securitydata.extraction.get_profile") +@pytest.fixture +def namespace_with_begin(namespace): + namespace.begin_date = begin_date_tuple + return namespace + + def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( logger, namespace, extractor ): @@ -72,7 +76,70 @@ def test_extract_when_is_advanced_query_and_has_end_date_exits(logger, namespace def test_extract_when_is_advanced_query_and_has_exposure_types_exits(logger, namespace): namespace.advanced_query = "some complex json" - namespace.exposure_types = [ExposureType.SHARED_TO_DOMAIN] + namespace.exposure_types = [ExposureTypeOptions.SHARED_TO_DOMAIN] + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_username_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.c42username = "Someone" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_actor_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.actor = "Someone" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_md5_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.md5 = "098f6bcd4621d373cade4e832627b4f6" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_sha256_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_source_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.source = "Gmail" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_filename_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.filename = "test.out" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_filepath_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.filepath = "path/to/file" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_process_owner_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.process_owner = "someone" + with pytest.raises(SystemExit): + extract(logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_tab_url_exists(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.tab_url = "https://www.example.com" with pytest.raises(SystemExit): extract(logger, namespace) @@ -84,6 +151,13 @@ def test_extract_when_is_advanced_query_and_has_incremental_mode_exits(logger, n extract(logger, namespace) +def test_extract_when_is_advanced_query_and_has_include_non_exposure_exits(logger, namespace): + namespace.advanced_query = "some complex json" + namespace.include_non_exposure_events = True + with pytest.raises(SystemExit): + extract(logger, namespace) + + def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( logger, namespace ): @@ -92,89 +166,70 @@ def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_do extract(logger, namespace) -def test_extract_when_is_not_advanced_query_uses_only_extract_method(logger, extractor, namespace): - extract(logger, namespace) +def test_extract_when_is_not_advanced_query_uses_only_extract_method( + logger, extractor, namespace_with_begin +): + extract(logger, namespace_with_begin) assert extractor.extract.call_count == 1 assert extractor.extract_raw.call_count == 0 -def test_extract_passed_through_given_exposure_types(logger, error_logger, namespace, extractor): - namespace.exposure_types = [ - ExposureType.IS_PUBLIC, - ExposureType.CLOUD_STORAGE, - ExposureType.APPLICATION_READ, - ] - extract(logger, namespace) - assert extractor.extract.call_args[0][0] == [ - ExposureType.IS_PUBLIC, - ExposureType.CLOUD_STORAGE, - ExposureType.APPLICATION_READ, - ] - - -def test_extract_when_not_given_begin_or_end_dates_uses_default_query( - logger, error_logger, namespace, extractor -): +def test_extract_when_not_given_begin_or_advanced_causes_exit(logger, extractor, namespace): namespace.begin_date = None - namespace.end_date = None - extract(logger, namespace) - actual_begin = parse_date_from_first_filter_value(extractor.extract.call_args[0][1]) - actual_end = parse_date_from_second_filter_value(extractor.extract.call_args[0][1]) - expected_begin = get_test_date(days_ago=60) - expected_end = datetime.utcnow() - assert (expected_begin - actual_begin).total_seconds() < 0.1 - assert (expected_end - actual_end).total_seconds() < 0.1 + namespace.advanced_query = None + with pytest.raises(SystemExit): + extract(logger, namespace) -def test_extract_when_given_begin_date_uses_expected_query( - logger, error_logger, namespace, extractor -): +def test_extract_when_given_begin_date_uses_expected_query(logger, namespace, extractor): namespace.begin_date = (get_test_date_str(days_ago=89),) extract(logger, namespace) - actual = get_first_filter_value_from_json(extractor.extract.call_args[0][1]) + actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) expected = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) assert actual == expected -def test_extract_when_given_begin_date_and_time_uses_expected_query( - logger, error_logger, namespace, extractor -): +def test_extract_when_given_begin_date_and_time_uses_expected_query(logger, namespace, extractor): namespace.begin_date = (get_test_date_str(days_ago=89), "15:33:02") extract(logger, namespace) - actual = get_first_filter_value_from_json(extractor.extract.call_args[0][1]) + actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) expected = "{0}T{1}.000Z".format(namespace.begin_date[0], namespace.begin_date[1]) assert actual == expected -def test_extract_when_given_end_date_uses_expected_query( - logger, error_logger, namespace, extractor -): - namespace.end_date = (get_test_date_str(days_ago=10),) - extract(logger, namespace) - actual = get_second_filter_value_from_json(extractor.extract.call_args[0][1]) - expected = "{0}T00:00:00.000Z".format(namespace.end_date[0]) +def test_extract_when_given_end_date_uses_expected_query(logger, namespace_with_begin, extractor): + namespace_with_begin.end_date = (get_test_date_str(days_ago=10),) + extract(logger, namespace_with_begin) + actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) + expected = "{0}T23:59:59.000Z".format(namespace_with_begin.end_date[0]) assert actual == expected def test_extract_when_given_end_date_and_time_uses_expected_query( - logger, error_logger, namespace, extractor + logger, namespace_with_begin, extractor ): - namespace.end_date = (get_test_date_str(days_ago=10), "12:00:11") - extract(logger, namespace) - actual = get_second_filter_value_from_json(extractor.extract.call_args[0][1]) - expected = "{0}T{1}.000Z".format(namespace.end_date[0], namespace.end_date[1]) + namespace_with_begin.end_date = (get_test_date_str(days_ago=10), "12:00:11") + extract(logger, namespace_with_begin) + actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) + expected = "{0}T{1}.000Z".format( + namespace_with_begin.end_date[0], namespace_with_begin.end_date[1] + ) assert actual == expected def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( - logger, error_logger, namespace, extractor + logger, namespace, extractor ): namespace.begin_date = (get_test_date_str(days_ago=89),) namespace.end_date = (get_test_date_str(days_ago=55), "13:44:44") extract(logger, namespace) - actual_begin_timestamp = get_first_filter_value_from_json(extractor.extract.call_args[0][1]) - actual_end_timestamp = get_second_filter_value_from_json(extractor.extract.call_args[0][1]) + actual_begin_timestamp = get_filter_value_from_json( + extractor.extract.call_args[0][0], filter_index=0 + ) + actual_end_timestamp = get_filter_value_from_json( + extractor.extract.call_args[0][0], filter_index=1 + ) expected_begin_timestamp = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) expected_end_timestamp = "{0}T{1}.000Z".format(namespace.end_date[0], namespace.end_date[1]) @@ -183,45 +238,158 @@ def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( def test_extract_when_given_min_timestamp_more_than_ninety_days_back_causes_exit( - logger, error_logger, namespace, extractor + logger, namespace, extractor ): namespace.begin_date = (get_test_date_str(days_ago=91), "12:51:00") with pytest.raises(SystemExit): extract(logger, namespace) -def test_extract_when_end_date_is_before_begin_date_causes_exit( - logger, error_logger, namespace, extractor -): +def test_extract_when_end_date_is_before_begin_date_causes_exit(logger, namespace, extractor): namespace.begin_date = (get_test_date_str(days_ago=5),) namespace.end_date = (get_test_date_str(days_ago=6),) with pytest.raises(SystemExit): extract(logger, namespace) -def test_extract_when_given_invalid_exposure_type_causes_exit( - logger, error_logger, namespace, extractor -): +def test_extract_when_given_invalid_exposure_type_causes_exit(logger, namespace, extractor): namespace.exposure_types = [ - ExposureType.APPLICATION_READ, + ExposureTypeOptions.APPLICATION_READ, "SomethingElseThatIsNotSupported", - ExposureType.IS_PUBLIC, + ExposureTypeOptions.IS_PUBLIC, ] with pytest.raises(SystemExit): extract(logger, namespace) -def test_extract_when_given_begin_date_with_len_3_causes_exit( - logger, error_logger, namespace, extractor -): +def test_extract_when_given_begin_date_with_len_3_causes_exit(logger, namespace, extractor): namespace.begin_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") with pytest.raises(SystemExit): extract(logger, namespace) def test_extract_when_given_end_date_with_len_3_causes_exit( - logger, error_logger, namespace, extractor + logger, namespace_with_begin, extractor ): - namespace.end_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") + namespace_with_begin.end_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") + with pytest.raises(SystemExit): + extract(logger, namespace_with_begin) + + +def test_extract_when_given_username_uses_username_filter(logger, namespace_with_begin, extractor): + namespace_with_begin.c42username = "test.testerson@example.com" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + DeviceUsername.eq(namespace_with_begin.c42username) + ) + + +def test_extract_when_given_actor_uses_actor_filter(logger, namespace_with_begin, extractor): + namespace_with_begin.actor = "test.testerson" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(Actor.eq(namespace_with_begin.actor)) + + +def test_extract_when_given_md5_uses_md5_filter(logger, namespace_with_begin, extractor): + namespace_with_begin.md5 = "098f6bcd4621d373cade4e832627b4f6" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(MD5.eq(namespace_with_begin.md5)) + + +def test_extract_when_given_sha256_uses_sha256_filter(logger, namespace_with_begin, extractor): + namespace_with_begin.sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(SHA256.eq(namespace_with_begin.sha256)) + + +def test_extract_when_given_source_uses_source_filter(logger, namespace_with_begin, extractor): + namespace_with_begin.source = "Gmail" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(Source.eq(namespace_with_begin.source)) + + +def test_extract_when_given_filename_uses_filename_filter(logger, namespace_with_begin, extractor): + namespace_with_begin.filename = "file.txt" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(FileName.eq(namespace_with_begin.filename)) + + +def test_extract_when_given_filepath_uses_filepath_filter(logger, namespace_with_begin, extractor): + namespace_with_begin.filepath = "/path/to/file.txt" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(FilePath.eq(namespace_with_begin.filepath)) + + +def test_extract_when_given_process_owner_uses_process_owner_filter( + logger, namespace_with_begin, extractor +): + namespace_with_begin.process_owner = "test.testerson" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + ProcessOwner.eq(namespace_with_begin.process_owner) + ) + + +def test_extract_when_given_tab_url_uses_process_tab_url_filter( + logger, namespace_with_begin, extractor +): + namespace_with_begin.tab_url = "https://www.example.com" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(TabURL.eq(namespace_with_begin.tab_url)) + + +def test_extract_when_given_exposure_types_uses_exposure_type_is_in_filter( + logger, namespace_with_begin, extractor +): + namespace_with_begin.exposure_types = ["ApplicationRead", "RemovableMedia", "CloudStorage"] + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + ExposureType.is_in(namespace_with_begin.exposure_types) + ) + + +def test_extract_when_given_include_non_exposure_does_not_include_exposure_type_exists( + mocker, logger, namespace_with_begin, extractor +): + namespace_with_begin.include_non_exposure_events = True + ExposureType.exists = mocker.MagicMock() + extract(logger, namespace_with_begin) + assert not ExposureType.exists.call_count + + +def test_extract_when_not_given_include_non_exposure_includes_exposure_type_exists( + logger, namespace_with_begin, extractor +): + namespace_with_begin.include_non_exposure_events = False + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(ExposureType.exists()) + + +def test_extract_when_given_multiple_search_args_uses_expected_filters( + logger, namespace_with_begin, extractor +): + namespace_with_begin.filepath = "/path/to/file.txt" + namespace_with_begin.process_owner = "test.testerson" + namespace_with_begin.tab_url = "https://www.example.com" + extract(logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(FilePath.eq("/path/to/file.txt")) + assert str(extractor.extract.call_args[0][2]) == str(ProcessOwner.eq("test.testerson")) + assert str(extractor.extract.call_args[0][3]) == str(TabURL.eq("https://www.example.com")) + + +def test_extract_when_given_include_non_exposure_and_exposure_types_causes_exit( + logger, namespace_with_begin, extractor +): + namespace_with_begin.exposure_types = ["ApplicationRead", "RemovableMedia", "CloudStorage"] + namespace_with_begin.include_non_exposure_events = True + with pytest.raises(SystemExit): + extract(logger, namespace_with_begin) + + +def test_extract_when_creating_sdk_throws_causes_exit(logger, extractor, namespace, mock_42): + def side_effect(): + raise Exception() + + mock_42.side_effect = side_effect with pytest.raises(SystemExit): extract(logger, namespace) diff --git a/tests/test_date_helper.py b/tests/test_date_helper.py deleted file mode 100644 index 718c32db5..000000000 --- a/tests/test_date_helper.py +++ /dev/null @@ -1,92 +0,0 @@ -import pytest -from datetime import datetime - -from code42cli.date_helper import create_event_timestamp_range -from .conftest import ( - get_first_filter_value_from_json, - get_second_filter_value_from_json, - parse_date_from_first_filter_value, - parse_date_from_second_filter_value, - get_test_date, - get_test_date_str, -) - - -def test_create_event_timestamp_range_when_given_no_args_creates_range_from_sixty_days_back_to_now(): - ts_range = create_event_timestamp_range() - actual_begin = parse_date_from_first_filter_value(ts_range) - actual_end = parse_date_from_second_filter_value(ts_range) - expected_begin = get_test_date(days_ago=60) - expected_end = datetime.utcnow() - assert (expected_begin - actual_begin).total_seconds() < 0.1 - assert (expected_end - actual_end).total_seconds() < 0.1 - - -def test_create_event_timestamp_range_when_given_begin_builds_expected_query(): - begin_date_tuple = (get_test_date_str(days_ago=89),) - ts_range = create_event_timestamp_range(begin_date_tuple) - actual = get_first_filter_value_from_json(ts_range) - expected = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) - assert actual == expected - - -def test_create_event_timestamp_range_when_given_begin_with_time_builds_expected_query(): - time_str = "3:12:33" - begin_date_tuple = (get_test_date_str(days_ago=89), time_str) - ts_range = create_event_timestamp_range(begin_date_tuple) - actual = get_first_filter_value_from_json(ts_range) - expected = "{0}T0{1}.000Z".format(begin_date_tuple[0], time_str) - assert actual == expected - - -def test_create_event_timestamp_range_when_given_end_builds_expected_query(): - end_date_tuple = (get_test_date_str(days_ago=10),) - ts_range = create_event_timestamp_range(None, end_date_tuple) - actual = get_second_filter_value_from_json(ts_range) - expected = "{0}T00:00:00.000Z".format(end_date_tuple[0]) - assert actual == expected - - -def test_create_event_timestamp_range_when_given_end_with_time_builds_expected_query(): - time_str = "11:22:43" - end_date_tuple = (get_test_date_str(days_ago=10), time_str) - ts_range = create_event_timestamp_range(None, end_date_tuple) - actual = get_second_filter_value_from_json(ts_range) - expected = "{0}T{1}.000Z".format(end_date_tuple[0], time_str) - assert actual == expected - - -def test_create_event_timestamp_range_when_given_both_begin_and_end_builds_expected_query(): - begin_date_tuple = (get_test_date_str(days_ago=89),) - end_date_tuple = (get_test_date_str(days_ago=55), "12:00:00") - ts_range = create_event_timestamp_range(begin_date_tuple, end_date_tuple) - actual_begin = get_first_filter_value_from_json(ts_range) - actual_end = get_second_filter_value_from_json(ts_range) - expected_begin = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) - expected_end = "{0}T{1}.000Z".format(end_date_tuple[0], end_date_tuple[1]) - assert actual_begin == expected_begin - assert actual_end == expected_end - - -def test_create_event_timestamp_range_when_begin_more_than_ninety_days_back_causes_value_error(): - begin_date_tuple = (get_test_date_str(days_ago=91),) - with pytest.raises(ValueError): - create_event_timestamp_range(begin_date_tuple) - - -def test_create_event_timestamp_when_end_is_before_begin_causes_value_error(): - begin_date_tuple = (get_test_date_str(days_ago=5),) - end_date_str = (get_test_date_str(days_ago=7),) - with pytest.raises(ValueError): - create_event_timestamp_range(begin_date_tuple, end_date_str) - - -def test_create_event_timestamp_when_given_minutes_ago_and_time_raises_value_error(): - with pytest.raises(ValueError): - create_event_timestamp_range("600", "12:00:00") - - -def test_create_event_timestamp_when_given_three_date_args_raises_value_error(): - begin_date_tuple = (get_test_date_str(days_ago=5), "12:00:00", "end_date=12:00:00") - with pytest.raises(ValueError): - create_event_timestamp_range(begin_date_tuple) From 77f67379e27df79d4f94282ddc89a0930b4a4352 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 4 Mar 2020 16:17:47 -0600 Subject: [PATCH 007/349] Feature/interactive errors (#9) --- CHANGELOG.md | 1 + src/code42cli/securitydata/extraction.py | 22 ++- .../securitydata/subcommands/write_to.py | 6 +- src/code42cli/util.py | 4 + .../securitydata/subcommands/test_write_to.py | 2 +- tests/securitydata/test_extraction.py | 150 ++++++++++++------ 6 files changed, 131 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6d48c02b..f9d95b2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added - Begin and end date now support specifying time: `code42 securitydata print -b 2020-02-02 12:00:00`. +- If running interactively and errors occur, you will be told them at the end of `code42 securitydata` commands. - New search arguments for `print`, `write-to`, and `send-to`: - `--c42username` - `--actor` diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index 80c23435a..f1e3a2c1f 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -12,7 +12,7 @@ from py42.sdk.file_event_query.file_query import MD5, SHA256, FileName, FilePath from code42cli.compat import str -from code42cli.util import print_error, print_bold +from code42cli.util import print_error, print_bold, is_interactive from code42cli.profile.profile import get_profile from code42cli.securitydata.options import ExposureType as ExposureTypeOptions from code42cli.securitydata import date_helper as date_helper @@ -22,6 +22,9 @@ from code42cli.securitydata.arguments.main import IS_INCREMENTAL_KEY +_EXCEPTIONS_OCCURRED = False + + def extract(output_logger, args): """Extracts file events using the given command-line arguments. @@ -38,12 +41,20 @@ def extract(output_logger, args): sdk = _get_sdk(profile, args.is_debug_mode) extractor = FileEventExtractor(sdk, handlers) _call_extract(extractor, args) + _handle_result() def _create_event_handlers(output_logger, is_incremental): handlers = FileEventHandlers() error_logger = get_error_logger() - handlers.handle_error = error_logger.error + + def handle_error(exception): + error_logger.error(exception) + global _EXCEPTIONS_OCCURRED + _EXCEPTIONS_OCCURRED = True + + handlers.handle_error = handle_error + if is_incremental: store = AEDCursorStore() handlers.record_cursor_position = store.replace_stored_insertion_timestamp @@ -116,7 +127,7 @@ def _verify_begin_date(begin_date): if not begin_date: print_error(u"'begin date' is required.") print(u"") - print(u"Try using '-b' or '--begin'. Use `-h` for more info.") + print_bold(u"Try using '-b' or '--begin'. Use `-h` for more info.") print(u"") exit(1) @@ -131,6 +142,11 @@ def _verify_exposure_types(exposure_types): exit(1) +def _handle_result(): + if is_interactive() and _EXCEPTIONS_OCCURRED: + print_error(u"View exceptions that occurred at [HOME]/.code42cli/log/code42_errors.") + + def _create_filters(args): filters = [_get_event_timestamp_filter(args)] not args.c42username or filters.append(DeviceUsername.eq(args.c42username)) diff --git a/src/code42cli/securitydata/subcommands/write_to.py b/src/code42cli/securitydata/subcommands/write_to.py index b064163d6..bf38b381c 100644 --- a/src/code42cli/securitydata/subcommands/write_to.py +++ b/src/code42cli/securitydata/subcommands/write_to.py @@ -10,7 +10,7 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser = subcommand_parser.add_parser("write-to") + parser = subcommand_parser.add_parser(u"write-to") parser.set_defaults(func=write_to) _add_filename_subcommand(parser) search_args.add_arguments_to_parser(parser) @@ -19,11 +19,11 @@ def init(subcommand_parser): def write_to(args): """Activates 'write-to' command. It gets security events and writes them to the given file.""" - logger = get_logger_for_file(args.filename, args.format) + logger = get_logger_for_file(args.output_file, args.format) extract(logger, args) def _add_filename_subcommand(parser): parser.add_argument( - action="store", dest="filename", help="The name of the local file to send output to." + action=u"store", dest=u"output_file", help=u"The name of the local file to send output to." ) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 229fc44c6..58262ab1a 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -37,3 +37,7 @@ def print_error(error_text): def print_bold(bold_text): print("\033[1m{}\033[0m".format(bold_text)) + + +def is_interactive(): + return sys.stdin.isatty() diff --git a/tests/securitydata/subcommands/test_write_to.py b/tests/securitydata/subcommands/test_write_to.py index a9e72c7d7..97f9953f8 100644 --- a/tests/securitydata/subcommands/test_write_to.py +++ b/tests/securitydata/subcommands/test_write_to.py @@ -11,7 +11,7 @@ @pytest.fixture def file_namespace(namespace): - namespace.filename = "out.txt" + namespace.output_file = "out.txt" namespace.format = "CEF" return namespace diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index 67e12f81f..25ccd7245 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -7,15 +7,14 @@ from py42.sdk.file_event_query.file_query import FilePath, FileName, SHA256, MD5 from code42cli.securitydata.options import ExposureType as ExposureTypeOptions -from code42cli.securitydata.extraction import extract +import code42cli.securitydata.extraction as extraction_module from .conftest import SECURITYDATA_NAMESPACE, begin_date_tuple from ..conftest import get_filter_value_from_json, get_test_date_str @pytest.fixture(autouse=True) def mock_42(mocker): - mock = mocker.patch("py42.sdk.SDK.create_using_local_account") - return mock + return mocker.patch("py42.sdk.SDK.create_using_local_account") @pytest.fixture @@ -30,7 +29,7 @@ def error_logger(mocker): return mocker.patch("{0}.logger_factory".format(SECURITYDATA_NAMESPACE)) -@pytest.fixture(autouse=True) +@pytest.fixture def extractor(mocker): mock = mocker.MagicMock() mock.extract_advanced = mocker.patch( @@ -55,7 +54,7 @@ def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( logger, namespace, extractor ): namespace.advanced_query = "some complex json" - extract(logger, namespace) + extraction_module.extract(logger, namespace) extractor.extract_advanced.assert_called_once_with("some complex json") assert extractor.extract.call_count == 0 @@ -64,98 +63,98 @@ def test_extract_when_is_advanced_query_and_has_begin_date_exits(logger, namespa namespace.advanced_query = "some complex json" namespace.begin_date = "begin date" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_end_date_exits(logger, namespace): namespace.advanced_query = "some complex json" namespace.end_date = "end date" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_exposure_types_exits(logger, namespace): namespace.advanced_query = "some complex json" namespace.exposure_types = [ExposureTypeOptions.SHARED_TO_DOMAIN] with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_username_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.c42username = "Someone" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_actor_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.actor = "Someone" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_md5_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.md5 = "098f6bcd4621d373cade4e832627b4f6" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_sha256_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_source_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.source = "Gmail" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_filename_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.filename = "test.out" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_filepath_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.filepath = "path/to/file" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_process_owner_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.process_owner = "someone" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_tab_url_exists(logger, namespace): namespace.advanced_query = "some complex json" namespace.tab_url = "https://www.example.com" with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_incremental_mode_exits(logger, namespace): namespace.advanced_query = "some complex json" namespace.is_incremental = True with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_include_non_exposure_exits(logger, namespace): namespace.advanced_query = "some complex json" namespace.include_non_exposure_events = True with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( @@ -163,13 +162,13 @@ def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_do ): namespace.advanced_query = "some complex json" namespace.is_incremental = False - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_is_not_advanced_query_uses_only_extract_method( logger, extractor, namespace_with_begin ): - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert extractor.extract.call_count == 1 assert extractor.extract_raw.call_count == 0 @@ -178,12 +177,12 @@ def test_extract_when_not_given_begin_or_advanced_causes_exit(logger, extractor, namespace.begin_date = None namespace.advanced_query = None with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_given_begin_date_uses_expected_query(logger, namespace, extractor): namespace.begin_date = (get_test_date_str(days_ago=89),) - extract(logger, namespace) + extraction_module.extract(logger, namespace) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) expected = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) assert actual == expected @@ -191,7 +190,7 @@ def test_extract_when_given_begin_date_uses_expected_query(logger, namespace, ex def test_extract_when_given_begin_date_and_time_uses_expected_query(logger, namespace, extractor): namespace.begin_date = (get_test_date_str(days_ago=89), "15:33:02") - extract(logger, namespace) + extraction_module.extract(logger, namespace) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) expected = "{0}T{1}.000Z".format(namespace.begin_date[0], namespace.begin_date[1]) assert actual == expected @@ -199,7 +198,7 @@ def test_extract_when_given_begin_date_and_time_uses_expected_query(logger, name def test_extract_when_given_end_date_uses_expected_query(logger, namespace_with_begin, extractor): namespace_with_begin.end_date = (get_test_date_str(days_ago=10),) - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) expected = "{0}T23:59:59.000Z".format(namespace_with_begin.end_date[0]) assert actual == expected @@ -209,7 +208,7 @@ def test_extract_when_given_end_date_and_time_uses_expected_query( logger, namespace_with_begin, extractor ): namespace_with_begin.end_date = (get_test_date_str(days_ago=10), "12:00:11") - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) expected = "{0}T{1}.000Z".format( namespace_with_begin.end_date[0], namespace_with_begin.end_date[1] @@ -222,7 +221,7 @@ def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( ): namespace.begin_date = (get_test_date_str(days_ago=89),) namespace.end_date = (get_test_date_str(days_ago=55), "13:44:44") - extract(logger, namespace) + extraction_module.extract(logger, namespace) actual_begin_timestamp = get_filter_value_from_json( extractor.extract.call_args[0][0], filter_index=0 @@ -242,14 +241,14 @@ def test_extract_when_given_min_timestamp_more_than_ninety_days_back_causes_exit ): namespace.begin_date = (get_test_date_str(days_ago=91), "12:51:00") with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_end_date_is_before_begin_date_causes_exit(logger, namespace, extractor): namespace.begin_date = (get_test_date_str(days_ago=5),) namespace.end_date = (get_test_date_str(days_ago=6),) with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_given_invalid_exposure_type_causes_exit(logger, namespace, extractor): @@ -259,13 +258,13 @@ def test_extract_when_given_invalid_exposure_type_causes_exit(logger, namespace, ExposureTypeOptions.IS_PUBLIC, ] with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_given_begin_date_with_len_3_causes_exit(logger, namespace, extractor): namespace.begin_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) def test_extract_when_given_end_date_with_len_3_causes_exit( @@ -273,12 +272,12 @@ def test_extract_when_given_end_date_with_len_3_causes_exit( ): namespace_with_begin.end_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") with pytest.raises(SystemExit): - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) def test_extract_when_given_username_uses_username_filter(logger, namespace_with_begin, extractor): namespace_with_begin.c42username = "test.testerson@example.com" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( DeviceUsername.eq(namespace_with_begin.c42username) ) @@ -286,37 +285,37 @@ def test_extract_when_given_username_uses_username_filter(logger, namespace_with def test_extract_when_given_actor_uses_actor_filter(logger, namespace_with_begin, extractor): namespace_with_begin.actor = "test.testerson" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(Actor.eq(namespace_with_begin.actor)) def test_extract_when_given_md5_uses_md5_filter(logger, namespace_with_begin, extractor): namespace_with_begin.md5 = "098f6bcd4621d373cade4e832627b4f6" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(MD5.eq(namespace_with_begin.md5)) def test_extract_when_given_sha256_uses_sha256_filter(logger, namespace_with_begin, extractor): namespace_with_begin.sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(SHA256.eq(namespace_with_begin.sha256)) def test_extract_when_given_source_uses_source_filter(logger, namespace_with_begin, extractor): namespace_with_begin.source = "Gmail" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(Source.eq(namespace_with_begin.source)) def test_extract_when_given_filename_uses_filename_filter(logger, namespace_with_begin, extractor): namespace_with_begin.filename = "file.txt" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(FileName.eq(namespace_with_begin.filename)) def test_extract_when_given_filepath_uses_filepath_filter(logger, namespace_with_begin, extractor): namespace_with_begin.filepath = "/path/to/file.txt" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(FilePath.eq(namespace_with_begin.filepath)) @@ -324,7 +323,7 @@ def test_extract_when_given_process_owner_uses_process_owner_filter( logger, namespace_with_begin, extractor ): namespace_with_begin.process_owner = "test.testerson" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( ProcessOwner.eq(namespace_with_begin.process_owner) ) @@ -334,7 +333,7 @@ def test_extract_when_given_tab_url_uses_process_tab_url_filter( logger, namespace_with_begin, extractor ): namespace_with_begin.tab_url = "https://www.example.com" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(TabURL.eq(namespace_with_begin.tab_url)) @@ -342,7 +341,7 @@ def test_extract_when_given_exposure_types_uses_exposure_type_is_in_filter( logger, namespace_with_begin, extractor ): namespace_with_begin.exposure_types = ["ApplicationRead", "RemovableMedia", "CloudStorage"] - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( ExposureType.is_in(namespace_with_begin.exposure_types) ) @@ -353,7 +352,7 @@ def test_extract_when_given_include_non_exposure_does_not_include_exposure_type_ ): namespace_with_begin.include_non_exposure_events = True ExposureType.exists = mocker.MagicMock() - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert not ExposureType.exists.call_count @@ -361,7 +360,7 @@ def test_extract_when_not_given_include_non_exposure_includes_exposure_type_exis logger, namespace_with_begin, extractor ): namespace_with_begin.include_non_exposure_events = False - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(ExposureType.exists()) @@ -371,7 +370,7 @@ def test_extract_when_given_multiple_search_args_uses_expected_filters( namespace_with_begin.filepath = "/path/to/file.txt" namespace_with_begin.process_owner = "test.testerson" namespace_with_begin.tab_url = "https://www.example.com" - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str(FilePath.eq("/path/to/file.txt")) assert str(extractor.extract.call_args[0][2]) == str(ProcessOwner.eq("test.testerson")) assert str(extractor.extract.call_args[0][3]) == str(TabURL.eq("https://www.example.com")) @@ -383,7 +382,7 @@ def test_extract_when_given_include_non_exposure_and_exposure_types_causes_exit( namespace_with_begin.exposure_types = ["ApplicationRead", "RemovableMedia", "CloudStorage"] namespace_with_begin.include_non_exposure_events = True with pytest.raises(SystemExit): - extract(logger, namespace_with_begin) + extraction_module.extract(logger, namespace_with_begin) def test_extract_when_creating_sdk_throws_causes_exit(logger, extractor, namespace, mock_42): @@ -392,4 +391,61 @@ def side_effect(): mock_42.side_effect = side_effect with pytest.raises(SystemExit): - extract(logger, namespace) + extraction_module.extract(logger, namespace) + + +def test_extract_when_global_variable_is_true_and_is_interactive_prints_error( + mocker, logger, error_logger, namespace_with_begin, extractor +): + mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") + mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") + mock_is_interactive_function.return_value = True + extraction_module._EXCEPTIONS_OCCURRED = True + extraction_module.extract(logger, namespace_with_begin) + assert mock_error_printer.call_count + + +def test_extract_when_global_variable_is_true_and_not_is_interactive_does_not_print_error( + mocker, logger, error_logger, namespace_with_begin, extractor +): + mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") + mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") + mock_is_interactive_function.return_value = False + extraction_module._EXCEPTIONS_OCCURRED = True + extraction_module.extract(logger, namespace_with_begin) + assert not mock_error_printer.call_count + + +def test_extract_when_global_variable_is_false_and_is_interactive_does_not_print_error( + mocker, logger, error_logger, namespace_with_begin, extractor +): + mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") + mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") + mock_is_interactive_function.return_value = True + extraction_module._EXCEPTIONS_OCCURRED = False + extraction_module.extract(logger, namespace_with_begin) + assert not mock_error_printer.call_count + + +def test_when_sdk_raises_exception_global_variable_gets_set( + mocker, logger, error_logger, namespace_with_begin, mock_42 +): + extraction_module._EXCEPTIONS_OCCURRED = False + mock_sdk = mocker.MagicMock() + + # For ease + mock = mocker.patch("code42cli.securitydata.extraction.is_interactive") + mock.return_value = False + + def sdk_side_effect(self, *args): + raise Exception() + + mock_sdk.security.search_file_events.side_effect = sdk_side_effect + mock_42.return_value = mock_sdk + + mocker.patch( + "c42eventextractor.extractors.FileEventExtractor._verify_compatibility_of_filter_groups" + ) + + extraction_module.extract(logger, namespace_with_begin) + assert extraction_module._EXCEPTIONS_OCCURRED From 38308d732b09b67eefcb1397152fe1564f1fcaa5 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 6 Mar 2020 08:32:08 -0600 Subject: [PATCH 008/349] Fix mock error logger and optimize imports (#10) --- src/code42cli/__init__.py | 2 +- src/code42cli/main.py | 2 +- src/code42cli/profile/config.py | 4 ++-- src/code42cli/profile/password.py | 5 +++-- src/code42cli/securitydata/cursor_store.py | 1 + src/code42cli/securitydata/date_helper.py | 1 + src/code42cli/securitydata/extraction.py | 17 +++++++++-------- src/code42cli/securitydata/logger_factory.py | 4 ++-- .../securitydata/subcommands/print_out.py | 4 ++-- .../securitydata/subcommands/send_to.py | 2 +- .../securitydata/subcommands/write_to.py | 2 +- src/code42cli/util.py | 1 + tests/securitydata/test_extraction.py | 2 +- tests/test_main.py | 2 +- 14 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/code42cli/__init__.py b/src/code42cli/__init__.py index c19bb91f8..8b1378917 100644 --- a/src/code42cli/__init__.py +++ b/src/code42cli/__init__.py @@ -1 +1 @@ -from code42cli.main import main + diff --git a/src/code42cli/main.py b/src/code42cli/main.py index dd9547f24..bf9c94a11 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,8 +1,8 @@ from argparse import ArgumentParser +import code42cli.securitydata.main as securitydata from code42cli.compat import str from code42cli.profile import profile -import code42cli.securitydata.main as securitydata def main(): diff --git a/src/code42cli/profile/config.py b/src/code42cli/profile/config.py index 9a56daa80..57121add7 100644 --- a/src/code42cli/profile/config.py +++ b/src/code42cli/profile/config.py @@ -1,10 +1,10 @@ from __future__ import print_function + import os from configparser import ConfigParser -from code42cli.compat import str import code42cli.util as util - +from code42cli.compat import str _DEFAULT_VALUE = u"__DEFAULT__" diff --git a/src/code42cli/profile/password.py b/src/code42cli/profile/password.py index f96199547..7b79937c4 100644 --- a/src/code42cli/profile/password.py +++ b/src/code42cli/profile/password.py @@ -1,11 +1,12 @@ from __future__ import print_function -import keyring + from getpass import getpass +import keyring + import code42cli.profile.config as config from code42cli.profile.config import ConfigurationKeys - _ROOT_SERVICE_NAME = u"code42cli" diff --git a/src/code42cli/securitydata/cursor_store.py b/src/code42cli/securitydata/cursor_store.py index 753f9942c..f74ce466a 100644 --- a/src/code42cli/securitydata/cursor_store.py +++ b/src/code42cli/securitydata/cursor_store.py @@ -1,4 +1,5 @@ from __future__ import with_statement + import sqlite3 from code42cli.util import get_user_project_path diff --git a/src/code42cli/securitydata/date_helper.py b/src/code42cli/securitydata/date_helper.py index ebd036d36..955f31bef 100644 --- a/src/code42cli/securitydata/date_helper.py +++ b/src/code42cli/securitydata/date_helper.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta + from c42eventextractor.common import convert_datetime_to_timestamp from py42.sdk.file_event_query.event_query import EventTimestamp diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index f1e3a2c1f..5759b1129 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -1,10 +1,12 @@ from __future__ import print_function + import json -from py42.sdk import SDK -from py42 import debug_level -from py42 import settings + from c42eventextractor import FileEventHandlers from c42eventextractor.extractors import FileEventExtractor +from py42 import debug_level +from py42 import settings +from py42.sdk import SDK from py42.sdk.file_event_query.cloud_query import Actor from py42.sdk.file_event_query.device_query import DeviceUsername from py42.sdk.file_event_query.event_query import Source @@ -12,15 +14,14 @@ from py42.sdk.file_event_query.file_query import MD5, SHA256, FileName, FilePath from code42cli.compat import str -from code42cli.util import print_error, print_bold, is_interactive from code42cli.profile.profile import get_profile -from code42cli.securitydata.options import ExposureType as ExposureTypeOptions from code42cli.securitydata import date_helper as date_helper +from code42cli.securitydata.arguments.main import IS_INCREMENTAL_KEY +from code42cli.securitydata.arguments.search import SearchArguments from code42cli.securitydata.cursor_store import AEDCursorStore from code42cli.securitydata.logger_factory import get_error_logger -from code42cli.securitydata.arguments.search import SearchArguments -from code42cli.securitydata.arguments.main import IS_INCREMENTAL_KEY - +from code42cli.securitydata.options import ExposureType as ExposureTypeOptions +from code42cli.util import print_error, print_bold, is_interactive _EXCEPTIONS_OCCURRED = False diff --git a/src/code42cli/securitydata/logger_factory.py b/src/code42cli/securitydata/logger_factory.py index 1af5be236..5fae2a4f1 100644 --- a/src/code42cli/securitydata/logger_factory.py +++ b/src/code42cli/securitydata/logger_factory.py @@ -1,8 +1,8 @@ -import sys import logging +import sys +from logging.handlers import RotatingFileHandler from threading import Lock -from logging.handlers import RotatingFileHandler from c42eventextractor.logging.formatters import ( FileEventDictToJSONFormatter, FileEventDictToCEFFormatter, diff --git a/src/code42cli/securitydata/subcommands/print_out.py b/src/code42cli/securitydata/subcommands/print_out.py index a72d68acd..8368cebc6 100644 --- a/src/code42cli/securitydata/subcommands/print_out.py +++ b/src/code42cli/securitydata/subcommands/print_out.py @@ -1,7 +1,7 @@ -from code42cli.securitydata.logger_factory import get_logger_for_stdout -from code42cli.securitydata.arguments import search as search_args from code42cli.securitydata.arguments import main as main_args +from code42cli.securitydata.arguments import search as search_args from code42cli.securitydata.extraction import extract +from code42cli.securitydata.logger_factory import get_logger_for_stdout def init(subcommand_parser): diff --git a/src/code42cli/securitydata/subcommands/send_to.py b/src/code42cli/securitydata/subcommands/send_to.py index dd416923a..fd2d28d26 100644 --- a/src/code42cli/securitydata/subcommands/send_to.py +++ b/src/code42cli/securitydata/subcommands/send_to.py @@ -1,7 +1,7 @@ -from code42cli.securitydata.logger_factory import get_logger_for_server from code42cli.securitydata.arguments import main as main_args from code42cli.securitydata.arguments import search as search_args from code42cli.securitydata.extraction import extract +from code42cli.securitydata.logger_factory import get_logger_for_server from code42cli.securitydata.options import ServerProtocol diff --git a/src/code42cli/securitydata/subcommands/write_to.py b/src/code42cli/securitydata/subcommands/write_to.py index bf38b381c..96e74ddab 100644 --- a/src/code42cli/securitydata/subcommands/write_to.py +++ b/src/code42cli/securitydata/subcommands/write_to.py @@ -1,7 +1,7 @@ -from code42cli.securitydata.logger_factory import get_logger_for_file from code42cli.securitydata.arguments import main as main_args from code42cli.securitydata.arguments import search as search_args from code42cli.securitydata.extraction import extract +from code42cli.securitydata.logger_factory import get_logger_for_file def init(subcommand_parser): diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 58262ab1a..abfa9d400 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,4 +1,5 @@ from __future__ import print_function, with_statement + import sys from os import path, makedirs diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index 25ccd7245..a5ac6bd9c 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -26,7 +26,7 @@ def logger(mocker): @pytest.fixture(autouse=True) def error_logger(mocker): - return mocker.patch("{0}.logger_factory".format(SECURITYDATA_NAMESPACE)) + return mocker.patch("{0}.extraction.get_error_logger".format(SECURITYDATA_NAMESPACE)) @pytest.fixture diff --git a/tests/test_main.py b/tests/test_main.py index ebbb2a34b..b64e1fd68 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,6 @@ import pytest -from code42cli import main +from code42cli.main import main @pytest.fixture From 840aeaec83deb5b86878ff3314631e352428a8bb Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 11 Mar 2020 11:02:28 -0500 Subject: [PATCH 009/349] Handle begin date complications (#11) --- CHANGELOG.md | 11 +++ README.md | 27 +++-- src/code42cli/securitydata/cursor_store.py | 6 +- src/code42cli/securitydata/date_helper.py | 43 ++++---- src/code42cli/securitydata/extraction.py | 62 ++++++++---- src/code42cli/securitydata/logger_factory.py | 10 +- .../subcommands/clear_checkpoint.py | 4 +- src/code42cli/util.py | 10 ++ tests/conftest.py | 5 +- tests/profile/test_config.py | 1 + tests/profile/test_profile.py | 4 +- .../subcommands/test_clear_checkpoint.py | 7 +- .../subcommands/test_print_out.py | 8 +- .../securitydata/subcommands/test_send_to.py | 12 +-- .../securitydata/subcommands/test_write_to.py | 8 +- tests/securitydata/test_cursor_store.py | 99 +++++++++++++++++-- tests/securitydata/test_date_helper.py | 38 +++---- tests/securitydata/test_extraction.py | 67 +++++++++++-- tests/securitydata/test_logger_factory.py | 44 ++++++--- tests/test_util.py | 13 +++ 20 files changed, 356 insertions(+), 123 deletions(-) create mode 100644 tests/test_util.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d95b2e1..3e566bce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Fixed + +- Fixed bug where port attached to `securitydata send-to` command was not properly applied. + +### Changed + +- Begin dates are no longer required for subsequent interactive `securitydata` commands. +- When provided, begin dates are now ignored on subsequent interactive `securitydata` commands. + ## 0.3.0 - 2020-03-04 ### Added diff --git a/README.md b/README.md index 6c907eb48..176414287 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Use the `code42` command to interact with your Code42 environment. `code42 securitydata` is a CLI tool for extracting AED events. -Additionally, `code42 securitydata` can record a checkpoint so that you only get events you have not previously gotten. +Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a checkpoint +(provided you do not change your query). ## Requirements @@ -46,19 +47,34 @@ Next, you can query for events and send them to three possible destination types To print events to stdout, do: ```bash -code42 securitydata print +code42 securitydata print -b 2020-02-02 ``` +Note that `-b` or `--begin` is usually required. +To specify a time, do: + +```bash +code42 securitydata print -b 2020-02-02 12:51 +``` +Begin date will be ignored if provided on subsequent queries using `-i`. + To write events to a file, do: ```bash -code42 securitydata write-to filename.txt +code42 securitydata write-to filename.txt -b 2020-02-02 ``` To send events to a server, do: ```bash -code42 securitydata send-to https://syslog.company.com -p TCP +code42 securitydata send-to syslog.company.com -p TCP -b 2020-02-02 ``` +To only get events that Code42 previously did not observe since you last recorded a checkpoint, use the `-i` flag. +```bash +code42 securitydata send-to syslog.company.com -i +``` +This is only guaranteed if you did not change your query. + + Each destination-type subcommand shares query parameters * `-t` (exposure types) * `-b` (begin date) @@ -75,8 +91,7 @@ Each destination-type subcommand shares query parameters * `--include-non-exposure` (does not work with `-t`) * `--advanced-query` (raw JSON query) -Note that you cannot use other query parameters if you use `--advanced-query`. - +You cannot use other query parameters if you use `--advanced-query`. To learn more about acceptable arguments, add the `-h` flag to `code42` or and of the destination-type subcommands. diff --git a/src/code42cli/securitydata/cursor_store.py b/src/code42cli/securitydata/cursor_store.py index f74ce466a..5ecb87fa2 100644 --- a/src/code42cli/securitydata/cursor_store.py +++ b/src/code42cli/securitydata/cursor_store.py @@ -7,7 +7,7 @@ _INSERTION_TIMESTAMP_FIELD_NAME = u"insertionTimestamp" -class SecurityEventCursorStore(object): +class BaseCursorStore(object): _PRIMARY_KEY_COLUMN_NAME = u"cursor_id" def __init__(self, db_table_name, db_file_path=None): @@ -52,11 +52,11 @@ def _is_empty(self): return int(query_result[0]) <= 0 -class AEDCursorStore(SecurityEventCursorStore): +class FileEventCursorStore(BaseCursorStore): _PRIMARY_KEY = 1 def __init__(self, db_file_path=None): - super(AEDCursorStore, self).__init__(u"aed_checkpoint", db_file_path) + super(FileEventCursorStore, self).__init__(u"aed_checkpoint", db_file_path) if self._is_empty(): self._init_table() diff --git a/src/code42cli/securitydata/date_helper.py b/src/code42cli/securitydata/date_helper.py index 955f31bef..7856bce36 100644 --- a/src/code42cli/securitydata/date_helper.py +++ b/src/code42cli/securitydata/date_helper.py @@ -3,28 +3,45 @@ from c42eventextractor.common import convert_datetime_to_timestamp from py42.sdk.file_event_query.event_query import EventTimestamp -_DEFAULT_LOOK_BACK_DAYS = 60 _MAX_LOOK_BACK_DAYS = 90 _FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format." -def create_event_timestamp_range(begin_date, end_date=None): - """Creates a `py42.sdk.file_event_query.event_query.EventTimestamp.in_range` filter - using the provided dates. If begin_date is None, it uses a date that is 60 days back. - If end_date is None, it uses the current UTC time. +def create_event_timestamp_filter(begin_date=None, end_date=None): + """Creates a `py42.sdk.file_event_query.event_query.EventTimestamp` filter using the given dates. + Returns None if not given a begin_date or an end_date. Args: - begin_date: The begin date for the range. If None, defaults to 60 days back from the current UTC time. - end_date: The end date for the range. If None, defaults to the current time. + begin_date: The begin date for the range. + end_date: The end date for the range. """ end_date = _get_end_date_with_eod_time_if_needed(end_date) + if begin_date and end_date: + return _create_in_range_filter(begin_date, end_date) + elif begin_date and not end_date: + return _create_on_or_after_filter(begin_date) + elif end_date and not begin_date: + return _create_on_or_before_filter(end_date) + + +def _create_in_range_filter(begin_date, end_date): min_timestamp = _parse_min_timestamp(begin_date) - max_timestamp = _parse_max_timestamp(end_date) + max_timestamp = _parse_timestamp(end_date) _verify_timestamp_order(min_timestamp, max_timestamp) return EventTimestamp.in_range(min_timestamp, max_timestamp) +def _create_on_or_after_filter(begin_date): + min_timestamp = _parse_min_timestamp(begin_date) + return EventTimestamp.on_or_after(min_timestamp) + + +def _create_on_or_before_filter(end_date): + max_timestamp = _parse_timestamp(end_date) + return EventTimestamp.on_or_before(max_timestamp) + + def _get_end_date_with_eod_time_if_needed(end_date): if end_date and len(end_date) == 1: return end_date[0], "23:59:59" @@ -40,12 +57,6 @@ def _parse_min_timestamp(begin_date_str): return min_timestamp -def _parse_max_timestamp(end_date_str): - if not end_date_str: - return _get_default_max_timestamp() - return _parse_timestamp(end_date_str) - - def _verify_timestamp_order(min_timestamp, max_timestamp): if min_timestamp is None or max_timestamp is None: return @@ -74,7 +85,3 @@ def _join_date_tuple(date_tuple): else: raise ValueError(_FORMAT_VALUE_ERROR_MESSAGE) return date_str - - -def _get_default_max_timestamp(): - return convert_datetime_to_timestamp(datetime.utcnow()) diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index 5759b1129..67903a779 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -18,7 +18,7 @@ from code42cli.securitydata import date_helper as date_helper from code42cli.securitydata.arguments.main import IS_INCREMENTAL_KEY from code42cli.securitydata.arguments.search import SearchArguments -from code42cli.securitydata.cursor_store import AEDCursorStore +from code42cli.securitydata.cursor_store import FileEventCursorStore from code42cli.securitydata.logger_factory import get_error_logger from code42cli.securitydata.options import ExposureType as ExposureTypeOptions from code42cli.util import print_error, print_bold, is_interactive @@ -37,15 +37,21 @@ def extract(output_logger, args): args: Command line args used to build up file event query filters. """ - handlers = _create_event_handlers(output_logger, args.is_incremental) + store = _create_cursor_store(args) + handlers = _create_event_handlers(output_logger, store) profile = get_profile() sdk = _get_sdk(profile, args.is_debug_mode) extractor = FileEventExtractor(sdk, handlers) - _call_extract(extractor, args) + _call_extract(extractor, store, args) _handle_result() -def _create_event_handlers(output_logger, is_incremental): +def _create_cursor_store(args): + if args.is_incremental: + return FileEventCursorStore() + + +def _create_event_handlers(output_logger, cursor_store): handlers = FileEventHandlers() error_logger = get_error_logger() @@ -56,10 +62,9 @@ def handle_error(exception): handlers.handle_error = handle_error - if is_incremental: - store = AEDCursorStore() - handlers.record_cursor_position = store.replace_stored_insertion_timestamp - handlers.get_cursor_position = store.get_stored_insertion_timestamp + if cursor_store: + handlers.record_cursor_position = cursor_store.replace_stored_insertion_timestamp + handlers.get_cursor_position = cursor_store.get_stored_insertion_timestamp def handle_response(response): response_dict = json.loads(response.text) @@ -86,9 +91,9 @@ def _get_sdk(profile, is_debug_mode): exit(1) -def _call_extract(extractor, args): +def _call_extract(extractor, cursor_store, args): if not _determine_if_advanced_query(args): - _verify_begin_date(args.begin_date) + _verify_begin_date_requirements(args, cursor_store) _verify_exposure_types(args.exposure_types) filters = _create_filters(args) extractor.extract(*filters) @@ -116,16 +121,8 @@ def _verify_compatibility_with_advanced_query(key, val): return True -def _get_event_timestamp_filter(args): - try: - return date_helper.create_event_timestamp_range(args.begin_date, args.end_date) - except ValueError as ex: - print_error(str(ex)) - exit(1) - - -def _verify_begin_date(begin_date): - if not begin_date: +def _verify_begin_date_requirements(args, cursor_store): + if _begin_date_is_required(args, cursor_store) and not args.begin_date: print_error(u"'begin date' is required.") print(u"") print_bold(u"Try using '-b' or '--begin'. Use `-h` for more info.") @@ -133,6 +130,17 @@ def _verify_begin_date(begin_date): exit(1) +def _begin_date_is_required(args, cursor_store): + if not args.is_incremental: + return True + required = cursor_store is not None and cursor_store.get_stored_insertion_timestamp() is None + + # Ignore begin date when is incremental mode, it is not required, and it was passed an argument. + if not required and args.begin_date: + args.begin_date = None + return required + + def _verify_exposure_types(exposure_types): if exposure_types is None: return @@ -143,13 +151,25 @@ def _verify_exposure_types(exposure_types): exit(1) +def _get_event_timestamp_filter(args): + try: + return date_helper.create_event_timestamp_filter(args.begin_date, args.end_date) + except ValueError as ex: + print_error(str(ex)) + exit(1) + + def _handle_result(): if is_interactive() and _EXCEPTIONS_OCCURRED: print_error(u"View exceptions that occurred at [HOME]/.code42cli/log/code42_errors.") def _create_filters(args): - filters = [_get_event_timestamp_filter(args)] + filters = [] + event_timestamp_filter = _get_event_timestamp_filter(args) + if event_timestamp_filter: + filters.append(event_timestamp_filter) + not args.c42username or filters.append(DeviceUsername.eq(args.c42username)) not args.actor or filters.append(Actor.eq(args.actor)) not args.md5 or filters.append(MD5.eq(args.md5)) diff --git a/src/code42cli/securitydata/logger_factory.py b/src/code42cli/securitydata/logger_factory.py index 5fae2a4f1..3bcad6228 100644 --- a/src/code42cli/securitydata/logger_factory.py +++ b/src/code42cli/securitydata/logger_factory.py @@ -12,7 +12,7 @@ from code42cli.compat import str from code42cli.securitydata.options import OutputFormat -from code42cli.util import get_user_project_path, print_error +from code42cli.util import get_user_project_path, print_error, get_url_parts _logger_deps_lock = Lock() @@ -56,7 +56,7 @@ def get_logger_for_server(hostname, protocol, output_format): """Gets the logger that sends logs to a server for the given format. Args: - hostname: The hostname of the server. + hostname: The hostname of the server. It may include the port. protocol: The transfer protocol for sending logs. output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. """ @@ -66,7 +66,11 @@ def get_logger_for_server(hostname, protocol, output_format): with _logger_deps_lock: if not _logger_has_handlers(logger): - handler = NoPrioritySysLogHandlerWrapper(hostname, protocol=protocol).handler + url_parts = get_url_parts(hostname) + port = url_parts[1] or 514 + handler = NoPrioritySysLogHandlerWrapper( + url_parts[0], port=port, protocol=protocol + ).handler return _init_logger(logger, handler, output_format) return logger diff --git a/src/code42cli/securitydata/subcommands/clear_checkpoint.py b/src/code42cli/securitydata/subcommands/clear_checkpoint.py index 4687f376c..d4961309a 100644 --- a/src/code42cli/securitydata/subcommands/clear_checkpoint.py +++ b/src/code42cli/securitydata/subcommands/clear_checkpoint.py @@ -1,4 +1,4 @@ -from code42cli.securitydata.cursor_store import AEDCursorStore +from code42cli.securitydata.cursor_store import FileEventCursorStore def init(subcommand_parser): @@ -15,7 +15,7 @@ def clear_checkpoint(*args): To use, run `code42 clear-checkpoint`. This affects `incremental` mode by causing it to behave like it has never been run before. """ - AEDCursorStore().reset() + FileEventCursorStore().reset() if __name__ == "__main__": diff --git a/src/code42cli/util.py b/src/code42cli/util.py index abfa9d400..9e4964292 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -3,6 +3,8 @@ import sys from os import path, makedirs +from code42cli.compat import urlparse + def get_input(prompt): """Uses correct input function based on Python version.""" @@ -42,3 +44,11 @@ def print_bold(bold_text): def is_interactive(): return sys.stdin.isatty() + + +def get_url_parts(url_str): + parts = url_str.split(u":") + port = None + if len(parts) > 1 and parts[1] != u"": + port = int(parts[1]) + return parts[0], port diff --git a/tests/conftest.py b/tests/conftest.py index 45a405667..98f7d1a73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,8 @@ -import pytest import json as json_module -from datetime import datetime, timedelta from argparse import Namespace +from datetime import datetime, timedelta + +import pytest from code42cli.profile.config import ConfigurationKeys diff --git a/tests/profile/test_config.py b/tests/profile/test_config.py index 9500856c6..e206e43c6 100644 --- a/tests/profile/test_config.py +++ b/tests/profile/test_config.py @@ -1,4 +1,5 @@ from __future__ import with_statement + import pytest import code42cli.profile.config as config diff --git a/tests/profile/test_profile.py b/tests/profile/test_profile.py index 9c618a6f5..d509529f3 100644 --- a/tests/profile/test_profile.py +++ b/tests/profile/test_profile.py @@ -1,5 +1,7 @@ -import pytest from argparse import ArgumentParser + +import pytest + from code42cli.profile import profile from .conftest import CONFIG_NAMESPACE, PASSWORD_NAMESPACE, PROFILE_NAMESPACE diff --git a/tests/securitydata/subcommands/test_clear_checkpoint.py b/tests/securitydata/subcommands/test_clear_checkpoint.py index 9cf085903..7dc664a78 100644 --- a/tests/securitydata/subcommands/test_clear_checkpoint.py +++ b/tests/securitydata/subcommands/test_clear_checkpoint.py @@ -1,18 +1,17 @@ import pytest -from ..conftest import SECURITYDATA_NAMESPACE from code42cli.securitydata.subcommands import clear_checkpoint as clearer - +from ..conftest import SECURITYDATA_NAMESPACE _CURSOR_STORE_PATH = "{0}.cursor_store".format(SECURITYDATA_NAMESPACE) @pytest.fixture def cursor_store(mocker): - mock_init = mocker.patch("{0}.AEDCursorStore.__init__".format(_CURSOR_STORE_PATH)) + mock_init = mocker.patch("{0}.FileEventCursorStore.__init__".format(_CURSOR_STORE_PATH)) mock_init.return_value = None mock = mocker.MagicMock() - mock_new = mocker.patch("{0}.AEDCursorStore.__new__".format(_CURSOR_STORE_PATH)) + mock_new = mocker.patch("{0}.FileEventCursorStore.__new__".format(_CURSOR_STORE_PATH)) mock_new.return_value = mock return mock diff --git a/tests/securitydata/subcommands/test_print_out.py b/tests/securitydata/subcommands/test_print_out.py index 97748051f..60b632140 100644 --- a/tests/securitydata/subcommands/test_print_out.py +++ b/tests/securitydata/subcommands/test_print_out.py @@ -1,10 +1,10 @@ -import pytest from argparse import ArgumentParser -from ..conftest import SUBCOMMANDS_NAMESPACE -from .conftest import ACCEPTABLE_ARGS -import code42cli.securitydata.subcommands.print_out as printer +import pytest +import code42cli.securitydata.subcommands.print_out as printer +from .conftest import ACCEPTABLE_ARGS +from ..conftest import SUBCOMMANDS_NAMESPACE _PRINT_PATH = "{0}.print_out".format(SUBCOMMANDS_NAMESPACE) diff --git a/tests/securitydata/subcommands/test_send_to.py b/tests/securitydata/subcommands/test_send_to.py index 7534c1ada..27df08274 100644 --- a/tests/securitydata/subcommands/test_send_to.py +++ b/tests/securitydata/subcommands/test_send_to.py @@ -1,17 +1,17 @@ -import pytest from argparse import ArgumentParser -from ..conftest import SUBCOMMANDS_NAMESPACE -from .conftest import ACCEPTABLE_ARGS -from code42cli.securitydata.subcommands import send_to as sender +import pytest +from code42cli.securitydata.subcommands import send_to as sender +from .conftest import ACCEPTABLE_ARGS +from ..conftest import SUBCOMMANDS_NAMESPACE _SEND_PATH = "{0}.send_to".format(SUBCOMMANDS_NAMESPACE) @pytest.fixture def server_namespace(namespace): - namespace.server = "https://www.syslog.example.com" + namespace.server = "www.syslog.example.com" namespace.protocol = "TCP" namespace.format = "CEF" return namespace @@ -61,7 +61,7 @@ def test_init_adds_parser_when_not_given_server_causes_system_exit(config_parser def test_send_to_uses_logger_for_server(server_namespace, logger_factory, extractor): sender.send_to(server_namespace) - logger_factory.assert_called_once_with("https://www.syslog.example.com", "TCP", "CEF") + logger_factory.assert_called_once_with("www.syslog.example.com", "TCP", "CEF") def test_send_to_calls_extract_with_expected_arguments( diff --git a/tests/securitydata/subcommands/test_write_to.py b/tests/securitydata/subcommands/test_write_to.py index 97f9953f8..379846751 100644 --- a/tests/securitydata/subcommands/test_write_to.py +++ b/tests/securitydata/subcommands/test_write_to.py @@ -1,10 +1,10 @@ -import pytest from argparse import ArgumentParser -from ..conftest import SUBCOMMANDS_NAMESPACE -from .conftest import ACCEPTABLE_ARGS -from code42cli.securitydata.subcommands import write_to as writer +import pytest +from code42cli.securitydata.subcommands import write_to as writer +from .conftest import ACCEPTABLE_ARGS +from ..conftest import SUBCOMMANDS_NAMESPACE _WRITE_PATH = "{0}.write_to".format(SUBCOMMANDS_NAMESPACE) diff --git a/tests/securitydata/test_cursor_store.py b/tests/securitydata/test_cursor_store.py index 331f898fb..3d40161b9 100644 --- a/tests/securitydata/test_cursor_store.py +++ b/tests/securitydata/test_cursor_store.py @@ -1,14 +1,17 @@ -import pytest from os import path -from code42cli.securitydata.cursor_store import SecurityEventCursorStore +import pytest +from c42eventextractor.extractors import INSERTION_TIMESTAMP_FIELD_NAME + +from code42cli.securitydata.cursor_store import BaseCursorStore, FileEventCursorStore -class TestSecurityEventCursorStore(object): - @pytest.fixture - def sqlite_connection(self, mocker): - return mocker.patch("sqlite3.connect") +@pytest.fixture +def sqlite_connection(mocker): + return mocker.patch("sqlite3.connect") + +class TestBaseCursorStore(object): def test_init_cursor_store_when_not_given_db_file_path_uses_expected_path_with_db_table_name_as_db_file_name( self, sqlite_connection ): @@ -16,10 +19,90 @@ def test_init_cursor_store_when_not_given_db_file_path_uses_expected_path_with_d expected_path = path.join(home_dir, ".code42cli/db") expected_db_name = "TEST" expected_db_file_path = "{0}/{1}.db".format(expected_path, expected_db_name) - SecurityEventCursorStore(expected_db_name) + BaseCursorStore(expected_db_name) sqlite_connection.assert_called_once_with(expected_db_file_path) def test_init_cursor_store_when_given_db_file_path_uses_given_path(self, sqlite_connection): expected_db_file_path = "Hey, look, I'm a file path..." - SecurityEventCursorStore("test", expected_db_file_path) + BaseCursorStore("test", expected_db_file_path) sqlite_connection.assert_called_once_with(expected_db_file_path) + + +class TestFileEventCursorStore(object): + MOCK_TEST_DB_PATH = "test_path.db" + + def test_reset_executes_expected_drop_table_query(self, sqlite_connection): + store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store.reset() + with store._connection as conn: + actual = conn.execute.call_args_list[0][0][0] + expected = "DROP TABLE aed_checkpoint" + assert actual == expected + + def test_reset_executes_expected_create_table_query(self, sqlite_connection): + store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store.reset() + with store._connection as conn: + actual = conn.execute.call_args_list[1][0][0] + expected = "CREATE TABLE aed_checkpoint (cursor_id, insertionTimestamp)" + assert actual == expected + + def test_reset_executes_expected_insert_query(self, sqlite_connection): + store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store._connection = sqlite_connection + store.reset() + with store._connection as conn: + actual = conn.execute.call_args[0][0] + expected = "INSERT INTO aed_checkpoint VALUES(?, null)" + assert actual == expected + + def test_reset_executes_query_with_expected_primary_key(self, sqlite_connection): + store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store._connection = sqlite_connection + store.reset() + with store._connection as conn: + actual = conn.execute.call_args[0][1][0] + expected = store._PRIMARY_KEY + assert actual == expected + + def test_get_stored_insertion_timestamp_executes_expected_select_query(self, sqlite_connection): + store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store.get_stored_insertion_timestamp() + with store._connection as conn: + expected = "SELECT {0} FROM aed_checkpoint WHERE cursor_id=?".format( + INSERTION_TIMESTAMP_FIELD_NAME + ) + actual = conn.cursor().execute.call_args[0][0] + assert actual == expected + + def test_get_stored_insertion_timestamp_executes_query_with_expected_primary_key( + self, sqlite_connection + ): + store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store.get_stored_insertion_timestamp() + with store._connection as conn: + actual = conn.cursor().execute.call_args[0][1][0] + expected = store._PRIMARY_KEY + assert actual == expected + + def test_replace_stored_insertion_timestamp_executes_expected_update_query( + self, sqlite_connection + ): + store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store.replace_stored_insertion_timestamp(123) + with store._connection as conn: + expected = "UPDATE aed_checkpoint SET {0}=? WHERE cursor_id=?".format( + INSERTION_TIMESTAMP_FIELD_NAME + ) + actual = conn.execute.call_args[0][0] + assert actual == expected + + def test_replace_stored_insertion_timestamp_executes_query_with_expected_primary_key( + self, sqlite_connection + ): + store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + new_insertion_timestamp = 123 + store.replace_stored_insertion_timestamp(new_insertion_timestamp) + with store._connection as conn: + actual = conn.execute.call_args[0][1][0] + assert actual == new_insertion_timestamp diff --git a/tests/securitydata/test_date_helper.py b/tests/securitydata/test_date_helper.py index 65427bf95..4dab3a502 100644 --- a/tests/securitydata/test_date_helper.py +++ b/tests/securitydata/test_date_helper.py @@ -1,6 +1,6 @@ import pytest -from code42cli.securitydata.date_helper import create_event_timestamp_range +from code42cli.securitydata.date_helper import create_event_timestamp_filter from .conftest import ( begin_date_tuple, begin_date_tuple_with_time, @@ -10,36 +10,36 @@ from ..conftest import get_filter_value_from_json, get_test_date_str -def test_create_event_timestamp_range_builds_expected_query(): - ts_range = create_event_timestamp_range(begin_date_tuple) +def test_create_event_timestamp_filter_builds_expected_query(): + ts_range = create_event_timestamp_filter(begin_date_tuple) actual = get_filter_value_from_json(ts_range, filter_index=0) expected = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) assert actual == expected -def test_create_event_timestamp_range_when_given_begin_with_time_builds_expected_query(): - ts_range = create_event_timestamp_range(begin_date_tuple_with_time) +def test_create_event_timestamp_filter_when_given_begin_with_time_builds_expected_query(): + ts_range = create_event_timestamp_filter(begin_date_tuple_with_time) actual = get_filter_value_from_json(ts_range, filter_index=0) expected = "{0}T0{1}.000Z".format(begin_date_tuple_with_time[0], begin_date_tuple_with_time[1]) assert actual == expected -def test_create_event_timestamp_range_when_given_end_builds_expected_query(): - ts_range = create_event_timestamp_range(begin_date_tuple, end_date_tuple) +def test_create_event_timestamp_filter_when_given_end_builds_expected_query(): + ts_range = create_event_timestamp_filter(begin_date_tuple, end_date_tuple) actual = get_filter_value_from_json(ts_range, filter_index=1) expected = "{0}T23:59:59.000Z".format(end_date_tuple[0]) assert actual == expected -def test_create_event_timestamp_range_when_given_end_with_time_builds_expected_query(): - ts_range = create_event_timestamp_range(begin_date_tuple, end_date_tuple_with_time) +def test_create_event_timestamp_filter_when_given_end_with_time_builds_expected_query(): + ts_range = create_event_timestamp_filter(begin_date_tuple, end_date_tuple_with_time) actual = get_filter_value_from_json(ts_range, filter_index=1) expected = "{0}T{1}.000Z".format(end_date_tuple_with_time[0], end_date_tuple_with_time[1]) assert actual == expected -def test_create_event_timestamp_range_when_given_both_begin_and_end_builds_expected_query(): - ts_range = create_event_timestamp_range(begin_date_tuple, end_date_tuple_with_time) +def test_create_event_timestamp_filter_when_given_both_begin_and_end_builds_expected_query(): + ts_range = create_event_timestamp_filter(begin_date_tuple, end_date_tuple_with_time) actual_begin = get_filter_value_from_json(ts_range, filter_index=0) actual_end = get_filter_value_from_json(ts_range, filter_index=1) expected_begin = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) @@ -48,25 +48,25 @@ def test_create_event_timestamp_range_when_given_both_begin_and_end_builds_expec assert actual_end == expected_end -def test_create_event_timestamp_range_when_begin_more_than_ninety_days_back_causes_value_error(): +def test_create_event_timestamp_filter_when_begin_more_than_ninety_days_back_causes_value_error(): begin_date_tuple = (get_test_date_str(days_ago=91),) with pytest.raises(ValueError): - create_event_timestamp_range(begin_date_tuple) + create_event_timestamp_filter(begin_date_tuple) -def test_create_event_timestamp_when_end_is_before_begin_causes_value_error(): +def test_create_event_timestamp_filter_when_end_is_before_begin_causes_value_error(): begin_date_tuple = (get_test_date_str(days_ago=5),) end_date_str = (get_test_date_str(days_ago=7),) with pytest.raises(ValueError): - create_event_timestamp_range(begin_date_tuple, end_date_str) + create_event_timestamp_filter(begin_date_tuple, end_date_str) -def test_create_event_timestamp_when_given_minutes_ago_and_time_raises_value_error(): +def test_create_event_timestamp_filter_when_given_minutes_ago_and_time_raises_value_error(): with pytest.raises(ValueError): - create_event_timestamp_range("600", "12:00:00") + create_event_timestamp_filter("600", "12:00:00") -def test_create_event_timestamp_when_given_three_date_args_raises_value_error(): +def test_create_event_timestamp_filter_when_given_three_date_args_raises_value_error(): begin_date_tuple = (get_test_date_str(days_ago=5), "12:00:00", "end_date=12:00:00") with pytest.raises(ValueError): - create_event_timestamp_range(begin_date_tuple) + create_event_timestamp_filter(begin_date_tuple) diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index a5ac6bd9c..b71deb03f 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -1,13 +1,12 @@ import pytest - from py42.sdk.alert_query import Actor from py42.sdk.file_event_query.device_query import DeviceUsername -from py42.sdk.file_event_query.event_query import Source +from py42.sdk.file_event_query.event_query import Source, EventTimestamp from py42.sdk.file_event_query.exposure_query import ExposureType, ProcessOwner, TabURL from py42.sdk.file_event_query.file_query import FilePath, FileName, SHA256, MD5 -from code42cli.securitydata.options import ExposureType as ExposureTypeOptions import code42cli.securitydata.extraction as extraction_module +from code42cli.securitydata.options import ExposureType as ExposureTypeOptions from .conftest import SECURITYDATA_NAMESPACE, begin_date_tuple from ..conftest import get_filter_value_from_json, get_test_date_str @@ -50,6 +49,14 @@ def namespace_with_begin(namespace): return namespace +def filter_term_is_in_call_args(extractor, term): + arg_filters = extractor.extract.call_args[0] + for f in arg_filters: + if term in str(f): + return True + return False + + def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( logger, namespace, extractor ): @@ -236,9 +243,10 @@ def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( assert actual_end_timestamp == expected_end_timestamp -def test_extract_when_given_min_timestamp_more_than_ninety_days_back_causes_exit( +def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( logger, namespace, extractor ): + namespace.is_incremental = False namespace.begin_date = (get_test_date_str(days_ago=91), "12:51:00") with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) @@ -251,6 +259,49 @@ def test_extract_when_end_date_is_before_begin_date_causes_exit(logger, namespac extraction_module.extract(logger, namespace) +def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + mocker, logger, namespace, extractor +): + namespace.begin_date = "2019-01-01" + namespace.is_incremental = True + mock_checkpoint = mocker.patch( + "code42cli.securitydata.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" + ) + mock_checkpoint.return_value = 22624624 + extraction_module.extract(logger, namespace) + assert not filter_term_is_in_call_args(extractor, EventTimestamp._term) + + +def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( + mocker, logger, namespace, extractor +): + namespace.begin_date = (get_test_date_str(days_ago=1),) + namespace.is_incremental = False + mock_checkpoint = mocker.patch( + "code42cli.securitydata.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" + ) + mock_checkpoint.return_value = 22624624 + extraction_module.extract(logger, namespace) + + actual_ts = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) + expected_ts = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) + assert actual_ts == expected_ts + assert filter_term_is_in_call_args(extractor, EventTimestamp._term) + + +def test_when_not_given_begin_date_and_is_incremental_but_no_stored_checkpoint_exists_causes_exit( + mocker, logger, namespace, extractor +): + namespace.begin_date = None + namespace.is_incremental = True + mock_checkpoint = mocker.patch( + "code42cli.securitydata.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" + ) + mock_checkpoint.return_value = None + with pytest.raises(SystemExit): + extraction_module.extract(logger, namespace) + + def test_extract_when_given_invalid_exposure_type_causes_exit(logger, namespace, extractor): namespace.exposure_types = [ ExposureTypeOptions.APPLICATION_READ, @@ -395,7 +446,7 @@ def side_effect(): def test_extract_when_global_variable_is_true_and_is_interactive_prints_error( - mocker, logger, error_logger, namespace_with_begin, extractor + mocker, logger, namespace_with_begin, extractor ): mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") @@ -406,7 +457,7 @@ def test_extract_when_global_variable_is_true_and_is_interactive_prints_error( def test_extract_when_global_variable_is_true_and_not_is_interactive_does_not_print_error( - mocker, logger, error_logger, namespace_with_begin, extractor + mocker, logger, namespace_with_begin, extractor ): mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") @@ -417,7 +468,7 @@ def test_extract_when_global_variable_is_true_and_not_is_interactive_does_not_pr def test_extract_when_global_variable_is_false_and_is_interactive_does_not_print_error( - mocker, logger, error_logger, namespace_with_begin, extractor + mocker, logger, namespace_with_begin, extractor ): mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") @@ -428,7 +479,7 @@ def test_extract_when_global_variable_is_false_and_is_interactive_does_not_print def test_when_sdk_raises_exception_global_variable_gets_set( - mocker, logger, error_logger, namespace_with_begin, mock_42 + mocker, logger, namespace_with_begin, mock_42 ): extraction_module._EXCEPTIONS_OCCURRED = False mock_sdk = mocker.MagicMock() diff --git a/tests/securitydata/test_logger_factory.py b/tests/securitydata/test_logger_factory.py index 4c5ed51b5..567ca059f 100644 --- a/tests/securitydata/test_logger_factory.py +++ b/tests/securitydata/test_logger_factory.py @@ -1,6 +1,7 @@ -import pytest import logging from logging.handlers import RotatingFileHandler + +import pytest from c42eventextractor.logging.formatters import ( FileEventDictToCEFFormatter, FileEventDictToJSONFormatter, @@ -15,7 +16,7 @@ def no_priority_syslog_handler(mocker): mock = mocker.patch("c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.handler") # Set handlers to empty list so it gets initialized each test - factory.get_logger_for_server("https://example.com", "TCP", "CEF").handlers = [] + factory.get_logger_for_server("example.com", "TCP", "CEF").handlers = [] return mock @@ -87,12 +88,12 @@ def test_get_logger_for_file_uses_given_file_name(): def test_get_logger_for_server_has_info_level(no_priority_syslog_handler): - logger = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + logger = factory.get_logger_for_server("example.com", "TCP", "CEF") assert logger.level == logging.INFO def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(no_priority_syslog_handler): - _ = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + _ = factory.get_logger_for_server("example.com", "TCP", "CEF") assert ( type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == FileEventDictToCEFFormatter ) @@ -101,8 +102,8 @@ def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(no_prior def test_get_logger_for_server_when_given_json_format_uses_json_formatter( no_priority_syslog_handler ): - factory.get_logger_for_server("https://example.com", "TCP", "JSON").handlers = [] - _ = factory.get_logger_for_server("https://example.com", "TCP", "JSON") + factory.get_logger_for_server("example.com", "TCP", "JSON").handlers = [] + _ = factory.get_logger_for_server("example.com", "TCP", "JSON") actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) assert actual == FileEventDictToJSONFormatter @@ -110,31 +111,46 @@ def test_get_logger_for_server_when_given_json_format_uses_json_formatter( def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter( no_priority_syslog_handler ): - factory.get_logger_for_server("https://example.com", "TCP", "RAW-JSON").handlers = [] - _ = factory.get_logger_for_server("https://example.com", "TCP", "RAW-JSON") + factory.get_logger_for_server("example.com", "TCP", "RAW-JSON").handlers = [] + _ = factory.get_logger_for_server("example.com", "TCP", "RAW-JSON") actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) assert actual == FileEventDictToRawJSONFormatter def test_get_logger_for_server_when_called_twice_only_has_one_handler(no_priority_syslog_handler): - _ = factory.get_logger_for_server("https://example.com", "TCP", "JSON") - logger = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + _ = factory.get_logger_for_server("example.com", "TCP", "JSON") + logger = factory.get_logger_for_server("example.com", "TCP", "CEF") assert len(logger.handlers) == 1 def test_get_logger_for_server_uses_no_priority_syslog_handler(no_priority_syslog_handler): - logger = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + logger = factory.get_logger_for_server("example.com", "TCP", "CEF") assert logger.handlers[0] == no_priority_syslog_handler -def test_get_logger_for_server_uses_given_host_and_protocol(mocker, no_priority_syslog_handler): +def test_get_logger_for_server_constructs_handler_with_expected_args( + mocker, no_priority_syslog_handler +): + no_priority_syslog_handler_wrapper = mocker.patch( + "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" + ) + no_priority_syslog_handler_wrapper.return_value = None + _ = factory.get_logger_for_server("example.com", "TCP", "CEF") + no_priority_syslog_handler_wrapper.assert_called_once_with( + "example.com", port=514, protocol="TCP" + ) + + +def test_get_logger_for_server_when_hostname_includes_port_constructs_handler_with_expected_args( + mocker, no_priority_syslog_handler +): no_priority_syslog_handler_wrapper = mocker.patch( "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" ) no_priority_syslog_handler_wrapper.return_value = None - _ = factory.get_logger_for_server("https://example.com", "TCP", "CEF") + _ = factory.get_logger_for_server("example.com:999", "TCP", "CEF") no_priority_syslog_handler_wrapper.assert_called_once_with( - "https://example.com", protocol="TCP" + "example.com", port=999, protocol="TCP" ) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 000000000..905be313a --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,13 @@ +from code42cli.util import get_url_parts + + +def test_get_url_parts_when_given_host_and_port_returns_expected_parts(): + url_str = "www.example.com:123" + parts = get_url_parts(url_str) + assert parts == ("www.example.com", 123) + + +def test_get_url_parts_when_given_host_without_port_returns_expected_parts(): + url_str = "www.example.com" + parts = get_url_parts(url_str) + assert parts == ("www.example.com", None) From 9557581ccde42709347ee652df563e6cc46fc668 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 11 Mar 2020 12:32:44 -0500 Subject: [PATCH 010/349] Add support for ANSI escape codes if run on Windows (for colored error messages). (#13) --- src/code42cli/main.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index bf9c94a11..548f75e7f 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,9 +1,21 @@ +import platform from argparse import ArgumentParser import code42cli.securitydata.main as securitydata from code42cli.compat import str from code42cli.profile import profile +# If on Windows, configure console session to handle ANSI escape sequences correctly +# source: https://bugs.python.org/issue29059 +if platform.system().lower() == "windows": + from ctypes import windll, c_int, byref + + stdout_handle = windll.kernel32.GetStdHandle(c_int(-11)) + mode = c_int(0) + windll.kernel32.GetConsoleMode(c_int(stdout_handle), byref(mode)) + mode = c_int(mode.value | 4) + windll.kernel32.SetConsoleMode(c_int(stdout_handle), mode) + def main(): code42_arg_parser = ArgumentParser() From a7c31cb6a0d395abb926eb290ee50f19fafcd234 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 11 Mar 2020 15:52:46 -0500 Subject: [PATCH 011/349] argparse uses `repr` on the choices when throwing an error, which causes our u-prefixed string literals to print out with the prefix when running the code42cli on python2: (#14) ``` # code42 securitydata notvalid usage: code42 securitydata [-h] {send-to,write-to,print,clear-checkpoint} ... code42 securitydata: error: invalid choice: 'notvalid' (choose from 'send-to', u'write-to', u'print', 'clear-checkpoint') ``` So I'm removing those from the relevant strings. --- src/code42cli/profile/profile.py | 8 ++++---- src/code42cli/securitydata/subcommands/print_out.py | 2 +- src/code42cli/securitydata/subcommands/write_to.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/code42cli/profile/profile.py b/src/code42cli/profile/profile.py index 75377a0a3..4c1520b89 100644 --- a/src/code42cli/profile/profile.py +++ b/src/code42cli/profile/profile.py @@ -26,13 +26,13 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser_profile = subcommand_parser.add_parser(u"profile") + parser_profile = subcommand_parser.add_parser("profile") parser_profile.set_defaults(func=show_profile) profile_subparsers = parser_profile.add_subparsers() - parser_for_show_command = profile_subparsers.add_parser(u"show") - parser_for_set_command = profile_subparsers.add_parser(u"set") - parser_for_reset_password = profile_subparsers.add_parser(u"reset-pw") + parser_for_show_command = profile_subparsers.add_parser("show") + parser_for_set_command = profile_subparsers.add_parser("set") + parser_for_reset_password = profile_subparsers.add_parser("reset-pw") parser_for_show_command.set_defaults(func=show_profile) parser_for_set_command.set_defaults(func=set_profile) diff --git a/src/code42cli/securitydata/subcommands/print_out.py b/src/code42cli/securitydata/subcommands/print_out.py index 8368cebc6..7642f4037 100644 --- a/src/code42cli/securitydata/subcommands/print_out.py +++ b/src/code42cli/securitydata/subcommands/print_out.py @@ -10,7 +10,7 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser = subcommand_parser.add_parser(u"print") + parser = subcommand_parser.add_parser("print") parser.set_defaults(func=print_out) search_args.add_arguments_to_parser(parser) main_args.add_arguments_to_parser(parser) diff --git a/src/code42cli/securitydata/subcommands/write_to.py b/src/code42cli/securitydata/subcommands/write_to.py index 96e74ddab..543dee68c 100644 --- a/src/code42cli/securitydata/subcommands/write_to.py +++ b/src/code42cli/securitydata/subcommands/write_to.py @@ -10,7 +10,7 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser = subcommand_parser.add_parser(u"write-to") + parser = subcommand_parser.add_parser("write-to") parser.set_defaults(func=write_to) _add_filename_subcommand(parser) search_args.add_arguments_to_parser(parser) From afb8a7d910747e7cd13f2378f1cc8abeafa5b071 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 12 Mar 2020 11:00:52 -0500 Subject: [PATCH 012/349] Feature/multiple profiles (#12) --- CHANGELOG.md | 21 +- README.md | 48 ++- src/code42cli/__version__.py | 2 +- src/code42cli/arguments.py | 25 ++ src/code42cli/compat.py | 4 + src/code42cli/main.py | 4 + src/code42cli/profile/config.py | 257 +++++++------- src/code42cli/profile/password.py | 28 +- src/code42cli/profile/profile.py | 231 ++++++++---- src/code42cli/securitydata/arguments/main.py | 11 - .../securitydata/arguments/search.py | 45 ++- src/code42cli/securitydata/cursor_store.py | 35 +- src/code42cli/securitydata/date_helper.py | 1 - src/code42cli/securitydata/extraction.py | 159 +++++---- .../subcommands/clear_checkpoint.py | 12 +- .../securitydata/subcommands/print_out.py | 4 +- .../securitydata/subcommands/send_to.py | 4 +- .../securitydata/subcommands/write_to.py | 4 +- src/code42cli/util.py | 16 +- tests/conftest.py | 54 +-- tests/profile/test_config.py | 328 +++++++++++------- tests/profile/test_password.py | 37 +- tests/profile/test_profile.py | 138 ++++---- tests/securitydata/conftest.py | 7 + .../subcommands/test_clear_checkpoint.py | 34 +- .../subcommands/test_print_out.py | 11 +- .../securitydata/subcommands/test_send_to.py | 4 +- .../securitydata/subcommands/test_write_to.py | 4 +- tests/securitydata/test_cursor_store.py | 64 +--- tests/securitydata/test_date_helper.py | 5 - tests/securitydata/test_extraction.py | 90 +++-- 31 files changed, 999 insertions(+), 688 deletions(-) create mode 100644 src/code42cli/arguments.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e566bce9..578c9f488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 0.4.0 - 2020-03-12 + +### Added + +- Support for multiple profiles: + - Optional `--profile` flag for: + - `securitydata write-to`, `print`, and `send-to`, + - `profile show`, `set`, and `reset-pw`. + - `code42 profile use` command for changing the default profile. + - `code42 profile list` command for listing all the available profiles. +- The following search args can now take multiple values: + - `--c42username`, + - `--actor`, + - `--md5`, + - `--sha256`, + - `--filename`, + - `--filepath`, + - `--processOwner`, + - `--tabURL` ### Fixed @@ -18,6 +36,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Begin dates are no longer required for subsequent interactive `securitydata` commands. - When provided, begin dates are now ignored on subsequent interactive `securitydata` commands. +- `--profile` arg is now required the first time setting up a profile. ## 0.3.0 - 2020-03-04 diff --git a/README.md b/README.md index 176414287..de4f76fec 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,20 @@ $ python setup.py install First, set your profile: ```bash -code42 profile set -s https://example.authority.com -u security.admin@example.com +code42 profile set --profile MY_FIRST_PROFILE -s https://example.authority.com -u security.admin@example.com ``` +The `--profile` flag is required the first time and it takes a name. +On subsequent uses of `set`, not specifying the profile will set the default profile. + Your profile contains the necessary properties for logging into Code42 servers. -After running this `code42 profile set`, you will be prompted about storing a password. -If you agree, you will be securely prompted to input your password. -Your password is not stored in plain-text, and is not shown when you do `code42 profile show`. -However, `code42 profile show` will confirm that there is a password set for your profile. +After running `code42 profile set`, the program prompts you about storing a password. +If you agree, you are then prompted to input your password. + +Your password is not stored in plain-text and is not shown when you do `code42 profile show`. +However, `code42 profile show` will confirm that a password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each time you run a command. -To ignore SSL errors, do: +For development purposes, you may need to ignore ssl errors. If you need to do this, do: ```bash code42 profile set --disable-ssl-errors ``` @@ -40,7 +44,19 @@ To re-enable SSL errors, do: code42 profile set --enable-ssl-errors ``` -Next, you can query for events and send them to three possible destination types +You can add multiple profiles with different names and the change the default profile with the `use` command: +```bash +code42 profile use MY_SECOND_PROFILE +``` +When the `--profile` flag is available on other commands, such as those in `securitydata`, +it will use that profile instead of the default one. + +To see all your profiles, do: +```bash +code42 profile list +``` + +Using the CLI, you can query for events and send them to three possible destination types: * stdout * A file * A server, such as SysLog @@ -58,6 +74,12 @@ code42 securitydata print -b 2020-02-02 12:51 ``` Begin date will be ignored if provided on subsequent queries using `-i`. +Use different format with `-f`: +```bash +code42 securitydata print -b 2020-02-02 -f CEF +``` +The available formats are CEF, JSON, and RAW-JSON. + To write events to a file, do: ```bash code42 securitydata write-to filename.txt -b 2020-02-02 @@ -74,6 +96,16 @@ code42 securitydata send-to syslog.company.com -i ``` This is only guaranteed if you did not change your query. +To send events to a server using a specific profile, do: +```bash +code42 securitydata send-to --profile PROFILE_FOR_RECURRING_JOB syslog.company.com -b 2020-02-02 -f CEF -i +``` + +You can also use wildcard for queries, but note, if they are not in quotes, you may get unexpected behavior. +```bash +code42 securitydata print --actor "*" +``` + Each destination-type subcommand shares query parameters * `-t` (exposure types) @@ -92,7 +124,7 @@ Each destination-type subcommand shares query parameters * `--advanced-query` (raw JSON query) You cannot use other query parameters if you use `--advanced-query`. -To learn more about acceptable arguments, add the `-h` flag to `code42` or and of the destination-type subcommands. +To learn more about acceptable arguments, add the `-h` flag to `code42` or any of the destination-type subcommands. # Known Issues diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 493f7415d..6a9beea82 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/code42cli/arguments.py b/src/code42cli/arguments.py new file mode 100644 index 000000000..e7f4d4f83 --- /dev/null +++ b/src/code42cli/arguments.py @@ -0,0 +1,25 @@ +PROFILE_NAME_KEY = u"profile_name" +PROFILE_HELP_MESSAGE = ( + u"The name of the profile containing your Code42 username and authority host address." +) + + +def add_arguments_to_parser(parser): + add_debug_arg(parser) + add_profile_name_arg(parser) + + +def add_debug_arg(parser): + parser.add_argument( + u"-d", + u"--debug", + dest=u"is_debug_mode", + action=u"store_true", + help=u"Turn on Debug logging.", + ) + + +def add_profile_name_arg(parser): + parser.add_argument( + u"--profile", action=u"store", dest=PROFILE_NAME_KEY, help=PROFILE_HELP_MESSAGE + ) diff --git a/src/code42cli/compat.py b/src/code42cli/compat.py index 3972f6f2c..9a94559a9 100644 --- a/src/code42cli/compat.py +++ b/src/code42cli/compat.py @@ -15,7 +15,11 @@ from urlparse import urljoin, urlparse str = unicode + + import repr as reprlib else: from urllib.parse import urljoin, urlparse str = str + + import reprlib diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 548f75e7f..7926e0479 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -34,3 +34,7 @@ def _run(parser): parser.print_help() return raise ex + + +if __name__ == "__main__": + main() diff --git a/src/code42cli/profile/config.py b/src/code42cli/profile/config.py index 57121add7..660feca76 100644 --- a/src/code42cli/profile/config.py +++ b/src/code42cli/profile/config.py @@ -6,136 +6,137 @@ import code42cli.util as util from code42cli.compat import str -_DEFAULT_VALUE = u"__DEFAULT__" - -class ConfigurationKeys(object): - USER_SECTION = u"Code42" +class ConfigAccessor(object): + DEFAULT_VALUE = u"__DEFAULT__" AUTHORITY_KEY = u"c42_authority_url" USERNAME_KEY = u"c42_username" IGNORE_SSL_ERRORS_KEY = u"ignore-ssl-errors" - INTERNAL_SECTION = u"Internal" - HAS_SET_PROFILE_KEY = u"has_set_profile" - - -def get_config_profile(): - """Get your config file profile.""" - parser = ConfigParser() - if not profile_has_been_set(): - util.print_error(u"Profile has not completed setup.") - print(u"") - print(u"To set, use: ") - util.print_bold(u"\tcode42 profile set -s -u ") - print(u"") - exit(1) - - return _get_config_profile_from_parser(parser) - - -def profile_has_been_set(): - """Whether you have, at one point in time, set your username and authority server URL.""" - parser = ConfigParser() - config_file_path = _get_config_file_path() - parser.read(config_file_path) - settings = parser[ConfigurationKeys.INTERNAL_SECTION] - return settings.getboolean(ConfigurationKeys.HAS_SET_PROFILE_KEY) - - -def mark_as_set_if_complete(): - if not _profile_can_be_set(): - return - parser = ConfigParser() - config_file_path = _get_config_file_path() - parser.read(config_file_path) - settings = parser[ConfigurationKeys.INTERNAL_SECTION] - settings[ConfigurationKeys.HAS_SET_PROFILE_KEY] = u"True" - _save(parser, ConfigurationKeys.HAS_SET_PROFILE_KEY) - - -def set_username(new_username): - parser = ConfigParser() - profile = _get_config_profile_from_parser(parser) - profile[ConfigurationKeys.USERNAME_KEY] = new_username - _save(parser, ConfigurationKeys.USERNAME_KEY) - - -def set_authority_url(new_url): - parser = ConfigParser() - profile = _get_config_profile_from_parser(parser) - profile[ConfigurationKeys.AUTHORITY_KEY] = new_url - _save(parser, ConfigurationKeys.AUTHORITY_KEY) - - -def set_ignore_ssl_errors(new_value): - parser = ConfigParser() - profile = _get_config_profile_from_parser(parser) - profile[ConfigurationKeys.IGNORE_SSL_ERRORS_KEY] = str(new_value) - _save(parser, ConfigurationKeys.IGNORE_SSL_ERRORS_KEY) - - -def _profile_can_be_set(): - """Whether your current username and authority URL are set, - but your profile has not been marked as set. - """ - parser = ConfigParser() - profile = _get_config_profile_from_parser(parser) - username = profile[ConfigurationKeys.USERNAME_KEY] - authority = profile[ConfigurationKeys.AUTHORITY_KEY] - return username != _DEFAULT_VALUE and authority != _DEFAULT_VALUE and not profile_has_been_set() - - -def _get_config_profile_from_parser(parser): - config_file_path = _get_config_file_path() - parser.read(config_file_path) - config = parser[ConfigurationKeys.USER_SECTION] - return config - - -def _get_config_file_path(): - path = u"{}config.cfg".format(util.get_user_project_path()) - if not os.path.exists(path) or not _verify_config_file(path): - _create_new_config_file(path) - return path - - -def _create_new_config_file(path): - config_parser = ConfigParser() - config_parser = _create_user_section(config_parser) - config_parser = _create_internal_section(config_parser) - _save(config_parser, None, path) - - -def _create_user_section(parser): - keys = ConfigurationKeys - parser.add_section(keys.USER_SECTION) - parser[keys.USER_SECTION] = {} - parser[keys.USER_SECTION][keys.AUTHORITY_KEY] = _DEFAULT_VALUE - parser[keys.USER_SECTION][keys.USERNAME_KEY] = _DEFAULT_VALUE - parser[keys.USER_SECTION][keys.IGNORE_SSL_ERRORS_KEY] = u"False" - return parser - - -def _create_internal_section(parser): - keys = ConfigurationKeys - parser.add_section(keys.INTERNAL_SECTION) - parser[keys.INTERNAL_SECTION] = {} - parser[keys.INTERNAL_SECTION][keys.HAS_SET_PROFILE_KEY] = u"False" - return parser - - -def _save(parser, key=None, path=None): - path = _get_config_file_path() if path is None else path - util.open_file(path, u"w+", lambda f: parser.write(f)) - if key is not None: - if key == ConfigurationKeys.HAS_SET_PROFILE_KEY: - print(u"You have completed setting up your profile!") + DEFAULT_PROFILE_IS_COMPLETE = u"default_profile_is_complete" + DEFAULT_PROFILE = u"default_profile" + _INTERNAL_SECTION = u"Internal" + + def __init__(self, parser): + self.parser = parser + self.path = u"{}config.cfg".format(util.get_user_project_path()) + if not os.path.exists(self.path): + self._create_internal_section() + self._save() else: - print(u"'{}' has been successfully updated".format(key)) - - -def _verify_config_file(path): - keys = ConfigurationKeys - config_parser = ConfigParser() - config_parser.read(path) - sections = config_parser.sections() - return keys.USER_SECTION in sections and keys.INTERNAL_SECTION in sections + self.parser.read(self.path) + + def get_profile(self, name=None): + """Returns the profile with the given name. + If name is None, returns the default profile. + If the name does not exist or there is no existing profile, it will throw an exception. + """ + name = name or self._default_profile_name + if name not in self.parser.sections() or name == self.DEFAULT_VALUE: + raise Exception(u"Profile does not exist.") + return self.parser[name] + + def get_all_profiles(self): + """Returns all the available profiles.""" + profiles = [] + names = self._get_profile_names() + for name in names: + profiles.append(self.get_profile(name)) + return profiles + + def create_profile_if_not_exists(self, name): + """Creates a new profile if one does not already exist for that name.""" + try: + self.get_profile(name) + except Exception as ex: + if name is not None and name != self.DEFAULT_VALUE: + self._create_profile_section(name) + else: + raise ex + + def switch_default_profile(self, new_default_name): + """Changes what is marked as the default profile in the internal section.""" + if self.get_profile(new_default_name) is None: + raise Exception(u"Profile does not exist.") + self._internal[self.DEFAULT_PROFILE] = new_default_name + self._save() + + def set_authority_url(self, new_value, profile_name=None): + """Sets 'authority URL' for a given profile. + Uses the default profile if name is None. + """ + profile = self.get_profile(profile_name) + profile[self.AUTHORITY_KEY] = new_value.strip() + self._save() + self._try_complete_setup(profile) + + def set_username(self, new_value, profile_name=None): + """Sets 'username' for a given profile. Uses the default profile if not given a name.""" + profile = self.get_profile(profile_name) + profile[self.USERNAME_KEY] = new_value.strip() + self._save() + self._try_complete_setup(profile) + + def set_ignore_ssl_errors(self, new_value, profile_name=None): + """Sets 'ignore_ssl_errors' for a given profile. + Uses the default profile if name is None. + """ + profile = self.get_profile(profile_name) + profile[self.IGNORE_SSL_ERRORS_KEY] = str(new_value) + self._save() + + @property + def _internal(self): + """The internal section of the config file.""" + return self.parser[self._INTERNAL_SECTION] + + @property + def _default_profile_name(self): + return self._internal[self.DEFAULT_PROFILE] + + def _get_profile_names(self): + names = list(self.parser.sections()) + names.remove(self._INTERNAL_SECTION) + return names + + def _create_internal_section(self): + self.parser.add_section(self._INTERNAL_SECTION) + self.parser[self._INTERNAL_SECTION] = {} + self.parser[self._INTERNAL_SECTION][self.DEFAULT_PROFILE_IS_COMPLETE] = str(False) + self.parser[self._INTERNAL_SECTION][self.DEFAULT_PROFILE] = self.DEFAULT_VALUE + + def _create_profile_section(self, name): + self.parser.add_section(name) + self.parser[name] = {} + self.parser[name][self.AUTHORITY_KEY] = self.DEFAULT_VALUE + self.parser[name][self.USERNAME_KEY] = self.DEFAULT_VALUE + self.parser[name][self.IGNORE_SSL_ERRORS_KEY] = str(False) + default_profile = self._internal.get(self.DEFAULT_PROFILE) + if default_profile is None or default_profile is self.DEFAULT_VALUE: + self._internal[self.DEFAULT_PROFILE] = name + + def _save(self): + util.open_file(self.path, u"w+", lambda f: self.parser.write(f)) + + def _try_complete_setup(self, profile): + if self._internal.getboolean(self.DEFAULT_PROFILE_IS_COMPLETE): + return + + authority = profile.get(self.AUTHORITY_KEY) + username = profile.get(self.USERNAME_KEY) + + authority_valid = authority and authority != self.DEFAULT_VALUE + username_valid = username and username != self.DEFAULT_VALUE + + if not authority_valid or not username_valid: + return + + self._internal[self.DEFAULT_PROFILE_IS_COMPLETE] = str(True) + if self._internal[self.DEFAULT_PROFILE] == self.DEFAULT_VALUE: + self._internal[self.DEFAULT_PROFILE] = profile.name + + self._save() + + +def get_config_accessor(): + """Create a ConfigAccessor with a ConfigParser as its parser.""" + return ConfigAccessor(ConfigParser()) diff --git a/src/code42cli/profile/password.py b/src/code42cli/profile/password.py index 7b79937c4..b08c85b0e 100644 --- a/src/code42cli/profile/password.py +++ b/src/code42cli/profile/password.py @@ -4,26 +4,27 @@ import keyring -import code42cli.profile.config as config -from code42cli.profile.config import ConfigurationKeys +from code42cli.profile.config import get_config_accessor, ConfigAccessor _ROOT_SERVICE_NAME = u"code42cli" -def get_password(): - """Gets your currently stored password for your username / authority URL combo.""" - profile = config.get_config_profile() - service_name = _get_service_name(profile) +def get_password(profile_name): + """Gets your currently stored password for your profile.""" + accessor = get_config_accessor() + profile = accessor.get_profile(profile_name) + service_name = _get_service_name(profile_name) username = _get_username(profile) password = keyring.get_password(service_name, username) return password -def set_password_from_prompt(): - """Prompts and sets your password for your username / authority URL combo.""" +def set_password_from_prompt(profile_name): + """Prompts and sets your password for your profile.""" password = getpass() - profile = config.get_config_profile() - service_name = _get_service_name(profile) + accessor = get_config_accessor() + profile = accessor.get_profile(profile_name) + service_name = _get_service_name(profile_name) username = _get_username(profile) keyring.set_password(service_name, username, password) print(u"'Code42 Password' updated.") @@ -34,10 +35,9 @@ def get_password_from_prompt(): return getpass() -def _get_service_name(profile): - authority_url = profile[ConfigurationKeys.AUTHORITY_KEY] - return u"{}::{}".format(_ROOT_SERVICE_NAME, authority_url) +def _get_service_name(profile_name): + return u"{}::{}".format(_ROOT_SERVICE_NAME, profile_name) def _get_username(profile): - return profile[ConfigurationKeys.USERNAME_KEY] + return profile[ConfigAccessor.USERNAME_KEY] diff --git a/src/code42cli/profile/profile.py b/src/code42cli/profile/profile.py index 4c1520b89..2c06d0082 100644 --- a/src/code42cli/profile/profile.py +++ b/src/code42cli/profile/profile.py @@ -1,23 +1,48 @@ from __future__ import print_function -import code42cli.profile.config as config +import code42cli.arguments as main_args import code42cli.profile.password as password -from code42cli.profile.config import ConfigurationKeys -from code42cli.util import get_input +from code42cli.compat import str +from code42cli.profile.config import get_config_accessor, ConfigAccessor +from code42cli.util import ( + get_input, + print_error, + print_set_profile_help, + print_no_existing_profile_message, +) class Code42Profile(object): - authority_url = u"" - username = u"" - ignore_ssl_errors = False + def __init__(self, profile): + self._profile = profile - @staticmethod - def get_password(): - pwd = password.get_password() + @property + def name(self): + return self._profile.name + + @property + def authority_url(self): + return self._profile[ConfigAccessor.AUTHORITY_KEY] + + @property + def username(self): + return self._profile[ConfigAccessor.USERNAME_KEY] + + @property + def ignore_ssl_error(self): + return self._profile[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] + + def get_password(self): + pwd = password.get_password(self.name) if not pwd: pwd = password.get_password_from_prompt() return pwd + def __str__(self): + return u"{0}: Username={1}, Authority URL={2}".format( + self.name, self.username, self.authority_url + ) + def init(subcommand_parser): """Sets up the `profile` subcommand with `show` and `set` subcommands. @@ -26,61 +51,101 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser_profile = subcommand_parser.add_parser("profile") - parser_profile.set_defaults(func=show_profile) + parser_profile = subcommand_parser.add_parser(u"profile") profile_subparsers = parser_profile.add_subparsers() - parser_for_show_command = profile_subparsers.add_parser("show") - parser_for_set_command = profile_subparsers.add_parser("set") - parser_for_reset_password = profile_subparsers.add_parser("reset-pw") + parser_for_show = profile_subparsers.add_parser(u"show") + parser_for_set = profile_subparsers.add_parser(u"set") + parser_for_reset_password = profile_subparsers.add_parser(u"reset-pw") + parser_for_list = profile_subparsers.add_parser(u"list") + parser_for_use = profile_subparsers.add_parser(u"use") - parser_for_show_command.set_defaults(func=show_profile) - parser_for_set_command.set_defaults(func=set_profile) + parser_for_show.set_defaults(func=show_profile) + parser_for_set.set_defaults(func=set_profile) parser_for_reset_password.set_defaults(func=prompt_for_password_reset) - _add_args_to_set_command(parser_for_set_command) + parser_for_list.set_defaults(func=list_profiles) + parser_for_use.set_defaults(func=use_profile) + + main_args.add_profile_name_arg(parser_for_show) + main_args.add_profile_name_arg(parser_for_reset_password) + _add_args_to_set_command(parser_for_set) + _add_positional_profile_arg(parser_for_use) + + +def get_profile(profile_name=None): + """Returns the profile for the given name.""" + accessor = get_config_accessor() + try: + profile = accessor.get_profile(profile_name) + return Code42Profile(profile) + except Exception as ex: + print_error(str(ex)) + print_set_profile_help() + exit(1) + + +def show_profile(args): + """Prints the given profile to stdout.""" + profile = get_profile(args.profile_name) + print(u"\n{0}:".format(profile.name)) + print(u"\t* {0} = {1}".format(ConfigAccessor.USERNAME_KEY, profile.username)) + print(u"\t* {0} = {1}".format(ConfigAccessor.AUTHORITY_KEY, profile.authority_url)) + print(u"\t* {0} = {1}".format(ConfigAccessor.IGNORE_SSL_ERRORS_KEY, profile.ignore_ssl_error)) + if password.get_password(args.profile_name) is not None: + print(u"\t* A password is set.") + print(u"") -def get_profile(): - """Returns the current profile object.""" - profile_values = config.get_config_profile() - profile = Code42Profile() - profile.authority_url = profile_values.get(ConfigurationKeys.AUTHORITY_KEY) - profile.username = profile_values.get(ConfigurationKeys.USERNAME_KEY) - profile.ignore_ssl_errors = profile_values.get(ConfigurationKeys.IGNORE_SSL_ERRORS_KEY) - return profile +def set_profile(args): + """Sets the given profile using command line arguments.""" + _verify_args_for_set(args) + accessor = get_config_accessor() + accessor.create_profile_if_not_exists(args.profile_name) + _try_set_authority_url(args, accessor) + _try_set_username(args, accessor) + _try_set_ignore_ssl_errors(args, accessor) + _prompt_for_allow_password_set(args) -def show_profile(*args): - """Prints the current profile to stdout.""" - profile = config.get_config_profile() - print(u"\nProfile:") - for key in profile: - print(u"\t* {} = {}".format(key, profile[key])) +def prompt_for_password_reset(args): + """Securely prompts for your password and then stores it using keyring.""" + password.set_password_from_prompt(args.profile_name) - if password.get_password() is not None: - print(u"\t* A password is set.") - print(u"") +def list_profiles(*args): + """Lists all profiles that exist for this OS user.""" + accessor = get_config_accessor() + profiles = accessor.get_all_profiles() + if not profiles: + print_no_existing_profile_message() + return + for profile in profiles: + profile = Code42Profile(profile) + print(profile) -def set_profile(args): - """Sets the current profile using command line arguments.""" - _try_set_authority_url(args) - _try_set_username(args) - _try_set_ignore_ssl_errors(args) - config.mark_as_set_if_complete() - _prompt_for_allow_password_set() +def use_profile(args): + """Changes the default profile to the given one.""" + accessor = get_config_accessor() + try: + accessor.switch_default_profile(args.profile_name) + except Exception as ex: + print_error(ex) + exit(1) -def prompt_for_password_reset(*args): - """Securely prompts for your password and then stores it using keyring.""" - password.set_password_from_prompt() + +def _add_args_to_set_command(parser_for_set): + main_args.add_profile_name_arg(parser_for_set) + _add_authority_arg(parser_for_set) + _add_username_arg(parser_for_set) + _add_disable_ssl_errors_arg(parser_for_set) + _add_enable_ssl_errors_arg(parser_for_set) -def _add_args_to_set_command(parser_for_set_command): - _add_authority_arg(parser_for_set_command) - _add_username_arg(parser_for_set_command) - _add_disable_ssl_errors_arg(parser_for_set_command) - _add_enable_ssl_errors_arg(parser_for_set_command) +def _add_positional_profile_arg(parser): + parser.add_argument( + action=u"store", dest=main_args.PROFILE_NAME_KEY, help=main_args.PROFILE_HELP_MESSAGE + ) def _add_authority_arg(parser): @@ -88,7 +153,7 @@ def _add_authority_arg(parser): u"-s", u"--server", action=u"store", - dest=ConfigurationKeys.AUTHORITY_KEY, + dest=ConfigAccessor.AUTHORITY_KEY, help=u"The full scheme, url and port of the Code42 server.", ) @@ -98,7 +163,7 @@ def _add_username_arg(parser): u"-u", u"--username", action=u"store", - dest=ConfigurationKeys.USERNAME_KEY, + dest=ConfigAccessor.USERNAME_KEY, help=u"The username of the Code42 API user.", ) @@ -109,7 +174,8 @@ def _add_disable_ssl_errors_arg(parser): action=u"store_true", default=None, dest=u"disable_ssl_errors", - help=u"Do not validate the SSL certificates of Code42 servers.", + help=u"For development purposes, do not validate the SSL certificates of Code42 servers." + u"This is not recommended unless it is required.", ) @@ -123,33 +189,68 @@ def _add_enable_ssl_errors_arg(parser): ) -def _set_has_args(args): - return args.c42_authority_url is not None or args.c42_username is not None +def _verify_args_for_set(args): + if _missing_default_profile(args): + print_error(u"Must supply a name when setting your profile for the first time.") + print_set_profile_help() + exit(1) + + missing_values = not args.c42_username and not args.c42_authority_url + if missing_values: + try: + profile = get_profile(args.profile_name) + missing_values = not profile.username and not profile.authority_url + except SystemExit: + missing_values = True + + if missing_values: + print_error(u"Missing username and authority url.") + print_set_profile_help() + exit(1) -def _try_set_authority_url(args): +def _try_set_authority_url(args, accessor): if args.c42_authority_url is not None: - config.set_authority_url(args.c42_authority_url) + accessor.set_authority_url(args.c42_authority_url, args.profile_name) -def _try_set_username(args): +def _try_set_username(args, accessor): if args.c42_username is not None: - config.set_username(args.c42_username) + accessor.set_username(args.c42_username, args.profile_name) -def _try_set_ignore_ssl_errors(args): +def _try_set_ignore_ssl_errors(args, accessor): if args.disable_ssl_errors is not None and not args.enable_ssl_errors: - config.set_ignore_ssl_errors(True) + accessor.set_ignore_ssl_errors(True, args.profile_name) if args.enable_ssl_errors is not None: - config.set_ignore_ssl_errors(False) + accessor.set_ignore_ssl_errors(False, args.profile_name) + + +def _missing_default_profile(args): + profile_name_arg_is_none = ( + args.profile_name is None or args.profile_name == ConfigAccessor.DEFAULT_VALUE + ) + return profile_name_arg_is_none and not _default_profile_exists() + + +def _default_profile_exists(): + try: + accessor = get_config_accessor() + profile = Code42Profile(accessor.get_profile()) + return profile.name and profile.name != ConfigAccessor.DEFAULT_VALUE + except Exception: + return False -def _prompt_for_allow_password_set(): +def _prompt_for_allow_password_set(args): answer = get_input(u"Would you like to set a password? (y/n): ") if answer.lower() == u"y": - prompt_for_password_reset() + prompt_for_password_reset(args) -if __name__ == "__main__": - show_profile() +def _log_key_save(key): + if key == ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE: + print(u"You have completed setting up your profile!") + else: + print(u"'{}' has been successfully updated".format(key)) diff --git a/src/code42cli/securitydata/arguments/main.py b/src/code42cli/securitydata/arguments/main.py index 11608344f..f3fa0d0f7 100644 --- a/src/code42cli/securitydata/arguments/main.py +++ b/src/code42cli/securitydata/arguments/main.py @@ -7,7 +7,6 @@ def add_arguments_to_parser(parser): _add_output_format_arg(parser) _add_incremental_arg(parser) - _add_debug_arg(parser) def _add_output_format_arg(parser): @@ -30,13 +29,3 @@ def _add_incremental_arg(parser): action=u"store_true", help=u"Only get events that were not previously retrieved.", ) - - -def _add_debug_arg(parser): - parser.add_argument( - u"-d", - u"--debug", - dest=u"is_debug_mode", - action=u"store_true", - help=u"Turn on Debug logging.", - ) diff --git a/src/code42cli/securitydata/arguments/search.py b/src/code42cli/securitydata/arguments/search.py index c686bac18..b10c2a071 100644 --- a/src/code42cli/securitydata/arguments/search.py +++ b/src/code42cli/securitydata/arguments/search.py @@ -23,15 +23,15 @@ class SearchArguments(object): BEGIN_DATE = u"begin_date" END_DATE = u"end_date" EXPOSURE_TYPES = u"exposure_types" - C42USERNAME = u"c42username" - ACTOR = u"actor" - MD5 = u"md5" - SHA256 = u"sha256" - SOURCE = u"source" - FILENAME = u"filename" - FILEPATH = u"filepath" - PROCESS_OWNER = u"process_owner" - TAB_URL = u"tab_url" + C42USERNAME = u"c42usernames" + ACTOR = u"actors" + MD5 = u"md5_hashes" + SHA256 = u"sha256_hashes" + SOURCE = u"sources" + FILENAME = u"filenames" + FILEPATH = u"filepaths" + PROCESS_OWNER = u"process_owners" + TAB_URL = u"tab_urls" INCLUDE_NON_EXPOSURE_EVENTS = u"include_non_exposure_events" def __iter__(self): @@ -105,72 +105,80 @@ def _add_exposure_types_arg(parser): def _add_username_arg(parser): parser.add_argument( u"--c42username", + nargs=u"+", action=u"store", dest=SearchArguments.C42USERNAME, - help=u"Limits events to endpoint events for this user.", + help=u"Limits events to endpoint events for these users.", ) def _add_actor_arg(parser): parser.add_argument( u"--actor", + nargs=u"+", action=u"store", dest=SearchArguments.ACTOR, - help=u"Limits events to only those enacted by this actor.", + help=u"Limits events to only those enacted by these actors.", ) def _add_md5_arg(parser): parser.add_argument( u"--md5", + nargs=u"+", action=u"store", dest=SearchArguments.MD5, - help=u"Limits events to file events where the file has this MD5 hash.", + help=u"Limits events to file events where the file has one of these MD5 hashes.", ) def _add_sha256_arg(parser): parser.add_argument( u"--sha256", + nargs=u"+", action=u"store", dest=SearchArguments.SHA256, - help=u"Limits events to file events where the file has this SHA256 hash.", + help=u"Limits events to file events where the file has one of these SHA256 hashes.", ) def _add_source_arg(parser): parser.add_argument( u"--source", + nargs=u"+", action=u"store", dest=SearchArguments.SOURCE, - help=u"Limits events to only those from this source. Example=Gmail.", + help=u"Limits events to only those from one of these sources. Example=Gmail.", ) def _add_filename_arg(parser): parser.add_argument( u"--filename", + nargs=u"+", action=u"store", dest=SearchArguments.FILENAME, - help=u"Limits events to file events where the file has this name.", + help=u"Limits events to file events where the file has one of these names.", ) def _add_filepath_arg(parser): parser.add_argument( u"--filepath", + nargs=u"+", action=u"store", dest=SearchArguments.FILEPATH, - help=u"Limits events to file events where the file is located at this path.", + help=u"Limits events to file events where the file is located at one of these paths.", ) def _add_process_owner_arg(parser): parser.add_argument( u"--processOwner", + nargs=u"+", action=u"store", dest=SearchArguments.PROCESS_OWNER, - help=u"Limits events to exposure events where this user " + help=u"Limits events to exposure events where one of these users " u"owns the process behind the exposure.", ) @@ -178,9 +186,10 @@ def _add_process_owner_arg(parser): def _add_tab_url_arg(parser): parser.add_argument( u"--tabURL", + nargs=u"+", action=u"store", dest=SearchArguments.TAB_URL, - help=u"Limits events to be exposure events with this destination tab URL.", + help=u"Limits events to be exposure events with one of these destination tab URLs.", ) diff --git a/src/code42cli/securitydata/cursor_store.py b/src/code42cli/securitydata/cursor_store.py index 5ecb87fa2..b3db2726f 100644 --- a/src/code42cli/securitydata/cursor_store.py +++ b/src/code42cli/securitydata/cursor_store.py @@ -33,6 +33,17 @@ def _set(self, column_name, new_value, primary_key): with self._connection as conn: conn.execute(query, (new_value, primary_key)) + def _row_exists(self, primary_key): + query = u"SELECT * FROM {0} WHERE {1}=?" + query = query.format(self._table_name, self._PRIMARY_KEY_COLUMN_NAME) + with self._connection as conn: + cursor = conn.cursor() + cursor.execute(query, (primary_key,)) + query_result = cursor.fetchone() + if not query_result: + return False + return True + def _drop_table(self): drop_query = u"DROP TABLE {0}".format(self._table_name) with self._connection as conn: @@ -53,16 +64,17 @@ def _is_empty(self): class FileEventCursorStore(BaseCursorStore): - _PRIMARY_KEY = 1 - - def __init__(self, db_file_path=None): - super(FileEventCursorStore, self).__init__(u"aed_checkpoint", db_file_path) + def __init__(self, profile_name, db_file_path=None): + self._primary_key = profile_name + super(FileEventCursorStore, self).__init__(u"file_event_checkpoints", db_file_path) if self._is_empty(): self._init_table() + if not self._row_exists(self._primary_key): + self._insert_new_row() def get_stored_insertion_timestamp(self): """Gets the last stored insertion timestamp.""" - rows = self._get(_INSERTION_TIMESTAMP_FIELD_NAME, self._PRIMARY_KEY) + rows = self._get(_INSERTION_TIMESTAMP_FIELD_NAME, self._primary_key) if rows and rows[0]: return rows[0][0] @@ -71,17 +83,16 @@ def replace_stored_insertion_timestamp(self, new_insertion_timestamp): self._set( column_name=_INSERTION_TIMESTAMP_FIELD_NAME, new_value=new_insertion_timestamp, - primary_key=self._PRIMARY_KEY, + primary_key=self._primary_key, ) - def reset(self): - self._drop_table() - self._init_table() - def _init_table(self): columns = u"{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, _INSERTION_TIMESTAMP_FIELD_NAME) create_table_query = u"CREATE TABLE {0} ({1})".format(self._table_name, columns) - insert_query = u"INSERT INTO {0} VALUES(?, null)".format(self._table_name) with self._connection as conn: conn.execute(create_table_query) - conn.execute(insert_query, (self._PRIMARY_KEY,)) + + def _insert_new_row(self): + insert_query = u"INSERT INTO {0} VALUES(?, null)".format(self._table_name) + with self._connection as conn: + conn.execute(insert_query, (self._primary_key,)) diff --git a/src/code42cli/securitydata/date_helper.py b/src/code42cli/securitydata/date_helper.py index 7856bce36..07fd85900 100644 --- a/src/code42cli/securitydata/date_helper.py +++ b/src/code42cli/securitydata/date_helper.py @@ -14,7 +14,6 @@ def create_event_timestamp_filter(begin_date=None, end_date=None): Args: begin_date: The begin date for the range. end_date: The end date for the range. - """ end_date = _get_end_date_with_eod_time_if_needed(end_date) if begin_date and end_date: diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index 67903a779..ed2a95d06 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -37,68 +37,28 @@ def extract(output_logger, args): args: Command line args used to build up file event query filters. """ - store = _create_cursor_store(args) + profile = get_profile(args.profile_name) + store = _create_cursor_store(args, profile) + filters = _get_filters(args, store) handlers = _create_event_handlers(output_logger, store) - profile = get_profile() sdk = _get_sdk(profile, args.is_debug_mode) extractor = FileEventExtractor(sdk, handlers) - _call_extract(extractor, store, args) + _call_extract(extractor, filters, args) _handle_result() -def _create_cursor_store(args): +def _create_cursor_store(args, profile): if args.is_incremental: - return FileEventCursorStore() + return FileEventCursorStore(profile.name) -def _create_event_handlers(output_logger, cursor_store): - handlers = FileEventHandlers() - error_logger = get_error_logger() - - def handle_error(exception): - error_logger.error(exception) - global _EXCEPTIONS_OCCURRED - _EXCEPTIONS_OCCURRED = True - - handlers.handle_error = handle_error - - if cursor_store: - handlers.record_cursor_position = cursor_store.replace_stored_insertion_timestamp - handlers.get_cursor_position = cursor_store.get_stored_insertion_timestamp - - def handle_response(response): - response_dict = json.loads(response.text) - events = response_dict.get(u"fileEvents") - for event in events: - output_logger.info(event) - - handlers.handle_response = handle_response - return handlers - - -def _get_sdk(profile, is_debug_mode): - if is_debug_mode: - settings.debug_level = debug_level.DEBUG - try: - return SDK.create_using_local_account( - profile.authority_url, profile.username, profile.get_password() - ) - except: - print_error( - u"Invalid credentials or host address. " - u"Verify your profile is set up correctly and that you are supplying the correct password." - ) - exit(1) - - -def _call_extract(extractor, cursor_store, args): +def _get_filters(args, cursor_store): if not _determine_if_advanced_query(args): _verify_begin_date_requirements(args, cursor_store) _verify_exposure_types(args.exposure_types) - filters = _create_filters(args) - extractor.extract(*filters) + return _create_filters(args) else: - extractor.extract_advanced(args.advanced_query) + return args.advanced_query def _determine_if_advanced_query(args): @@ -113,14 +73,6 @@ def _determine_if_advanced_query(args): return False -def _verify_compatibility_with_advanced_query(key, val): - if val is not None: - is_other_search_arg = key in SearchArguments() and key != SearchArguments.ADVANCED_QUERY - is_incremental = key == IS_INCREMENTAL_KEY and val - return not is_other_search_arg and not is_incremental - return True - - def _verify_begin_date_requirements(args, cursor_store): if _begin_date_is_required(args, cursor_store) and not args.begin_date: print_error(u"'begin date' is required.") @@ -151,6 +103,23 @@ def _verify_exposure_types(exposure_types): exit(1) +def _create_filters(args): + filters = [] + event_timestamp_filter = _get_event_timestamp_filter(args) + not event_timestamp_filter or filters.append(event_timestamp_filter) + not args.c42usernames or filters.append(DeviceUsername.is_in(args.c42usernames)) + not args.actors or filters.append(Actor.is_in(args.actors)) + not args.md5_hashes or filters.append(MD5.is_in(args.md5_hashes)) + not args.sha256_hashes or filters.append(SHA256.is_in(args.sha256_hashes)) + not args.sources or filters.append(Source.is_in(args.sources)) + not args.filenames or filters.append(FileName.is_in(args.filenames)) + not args.filepaths or filters.append(FilePath.is_in(args.filepaths)) + not args.process_owners or filters.append(ProcessOwner.is_in(args.process_owners)) + not args.tab_urls or filters.append(TabURL.is_in(args.tab_urls)) + _try_append_exposure_types_filter(filters, args) + return filters + + def _get_event_timestamp_filter(args): try: return date_helper.create_event_timestamp_filter(args.begin_date, args.end_date) @@ -159,30 +128,68 @@ def _get_event_timestamp_filter(args): exit(1) +def _create_event_handlers(output_logger, cursor_store): + handlers = FileEventHandlers() + error_logger = get_error_logger() + + def handle_error(exception): + error_logger.error(exception) + global _EXCEPTIONS_OCCURRED + _EXCEPTIONS_OCCURRED = True + + handlers.handle_error = handle_error + + if cursor_store: + handlers.record_cursor_position = cursor_store.replace_stored_insertion_timestamp + handlers.get_cursor_position = cursor_store.get_stored_insertion_timestamp + + def handle_response(response): + response_dict = json.loads(response.text) + events = response_dict.get(u"fileEvents") + for event in events: + output_logger.info(event) + + handlers.handle_response = handle_response + return handlers + + +def _get_sdk(profile, is_debug_mode): + if is_debug_mode: + settings.debug_level = debug_level.DEBUG + try: + password = profile.get_password() + return SDK.create_using_local_account(profile.authority_url, profile.username, password) + except Exception: + print_error( + u"Invalid credentials or host address. " + u"Verify your profile is set up correctly and that you are supplying the correct password." + ) + exit(1) + + +def _call_extract(extractor, filters, args): + if args.advanced_query: + extractor.extract_advanced(args.advanced_query) + else: + extractor.extract(*filters) + + +def _verify_compatibility_with_advanced_query(key, val): + if key == SearchArguments.INCLUDE_NON_EXPOSURE_EVENTS and not val: + return True + + if val is not None: + is_other_search_arg = key in SearchArguments() and key != SearchArguments.ADVANCED_QUERY + is_incremental = key == IS_INCREMENTAL_KEY and val + return not is_other_search_arg and not is_incremental + return True + + def _handle_result(): if is_interactive() and _EXCEPTIONS_OCCURRED: print_error(u"View exceptions that occurred at [HOME]/.code42cli/log/code42_errors.") -def _create_filters(args): - filters = [] - event_timestamp_filter = _get_event_timestamp_filter(args) - if event_timestamp_filter: - filters.append(event_timestamp_filter) - - not args.c42username or filters.append(DeviceUsername.eq(args.c42username)) - not args.actor or filters.append(Actor.eq(args.actor)) - not args.md5 or filters.append(MD5.eq(args.md5)) - not args.sha256 or filters.append(SHA256.eq(args.sha256)) - not args.source or filters.append(Source.eq(args.source)) - not args.filename or filters.append(FileName.eq(args.filename)) - not args.filepath or filters.append(FilePath.eq(args.filepath)) - not args.process_owner or filters.append(ProcessOwner.eq(args.process_owner)) - not args.tab_url or filters.append(TabURL.eq(args.tab_url)) - _try_append_exposure_types_filter(filters, args) - return filters - - def _try_append_exposure_types_filter(filters, args): exposure_filter = _create_exposure_type_filter(args) if exposure_filter: diff --git a/src/code42cli/securitydata/subcommands/clear_checkpoint.py b/src/code42cli/securitydata/subcommands/clear_checkpoint.py index d4961309a..b59d04eed 100644 --- a/src/code42cli/securitydata/subcommands/clear_checkpoint.py +++ b/src/code42cli/securitydata/subcommands/clear_checkpoint.py @@ -1,3 +1,5 @@ +from code42cli.arguments import add_profile_name_arg +from code42cli.profile.profile import get_profile from code42cli.securitydata.cursor_store import FileEventCursorStore @@ -7,16 +9,14 @@ def init(subcommand_parser): subcommand_parser: The subparsers group created by the parent parser. """ parser = subcommand_parser.add_parser("clear-checkpoint") + add_profile_name_arg(parser) parser.set_defaults(func=clear_checkpoint) -def clear_checkpoint(*args): +def clear_checkpoint(args): """Removes the stored checkpoint that keeps track of the last event you got. To use, run `code42 clear-checkpoint`. This affects `incremental` mode by causing it to behave like it has never been run before. """ - FileEventCursorStore().reset() - - -if __name__ == "__main__": - clear_checkpoint() + profile_name = args.profile_name or get_profile().name + FileEventCursorStore(profile_name).replace_stored_insertion_timestamp(None) diff --git a/src/code42cli/securitydata/subcommands/print_out.py b/src/code42cli/securitydata/subcommands/print_out.py index 7642f4037..cf10c8185 100644 --- a/src/code42cli/securitydata/subcommands/print_out.py +++ b/src/code42cli/securitydata/subcommands/print_out.py @@ -1,4 +1,5 @@ -from code42cli.securitydata.arguments import main as main_args +import code42cli.arguments as main_args +from code42cli.securitydata.arguments import main as securitydata_main_args from code42cli.securitydata.arguments import search as search_args from code42cli.securitydata.extraction import extract from code42cli.securitydata.logger_factory import get_logger_for_stdout @@ -13,6 +14,7 @@ def init(subcommand_parser): parser = subcommand_parser.add_parser("print") parser.set_defaults(func=print_out) search_args.add_arguments_to_parser(parser) + securitydata_main_args.add_arguments_to_parser(parser) main_args.add_arguments_to_parser(parser) diff --git a/src/code42cli/securitydata/subcommands/send_to.py b/src/code42cli/securitydata/subcommands/send_to.py index fd2d28d26..592c3416e 100644 --- a/src/code42cli/securitydata/subcommands/send_to.py +++ b/src/code42cli/securitydata/subcommands/send_to.py @@ -1,4 +1,5 @@ -from code42cli.securitydata.arguments import main as main_args +import code42cli.arguments as main_args +from code42cli.securitydata.arguments import main as securitydata_main_args from code42cli.securitydata.arguments import search as search_args from code42cli.securitydata.extraction import extract from code42cli.securitydata.logger_factory import get_logger_for_server @@ -16,6 +17,7 @@ def init(subcommand_parser): _add_server_arg(parser) _add_protocol_arg(parser) search_args.add_arguments_to_parser(parser) + securitydata_main_args.add_arguments_to_parser(parser) main_args.add_arguments_to_parser(parser) diff --git a/src/code42cli/securitydata/subcommands/write_to.py b/src/code42cli/securitydata/subcommands/write_to.py index 543dee68c..e190bc783 100644 --- a/src/code42cli/securitydata/subcommands/write_to.py +++ b/src/code42cli/securitydata/subcommands/write_to.py @@ -1,4 +1,5 @@ -from code42cli.securitydata.arguments import main as main_args +import code42cli.arguments as main_args +from code42cli.securitydata.arguments import main as securitydata_main_args from code42cli.securitydata.arguments import search as search_args from code42cli.securitydata.extraction import extract from code42cli.securitydata.logger_factory import get_logger_for_file @@ -14,6 +15,7 @@ def init(subcommand_parser): parser.set_defaults(func=write_to) _add_filename_subcommand(parser) search_args.add_arguments_to_parser(parser) + securitydata_main_args.add_arguments_to_parser(parser) main_args.add_arguments_to_parser(parser) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 9e4964292..ae107ea91 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -3,8 +3,6 @@ import sys from os import path, makedirs -from code42cli.compat import urlparse - def get_input(prompt): """Uses correct input function based on Python version.""" @@ -35,7 +33,7 @@ def open_file(file_path, mode, action): def print_error(error_text): """Prints red text.""" - print("\033[91mERROR: {}\033[0m".format(error_text)) + print("\033[91mUSAGE ERROR: {}\033[0m".format(error_text)) def print_bold(bold_text): @@ -46,6 +44,18 @@ def is_interactive(): return sys.stdin.isatty() +def print_no_existing_profile_message(): + print_error(u"No existing profile.") + print_set_profile_help() + + +def print_set_profile_help(): + print(u"") + print(u"To add a profile, use: ") + print_bold(u"\tcode42 profile set --profile -s -u ") + print(u"") + + def get_url_parts(url_str): parts = url_str.split(u":") port = None diff --git a/tests/conftest.py b/tests/conftest.py index 98f7d1a73..7c3df3e6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,64 +4,30 @@ import pytest -from code42cli.profile.config import ConfigurationKeys - - -@pytest.fixture -def config_profile(mocker): - mock_config = mocker.patch("code42cli.profile.config.get_config_profile") - mock_config.return_value = { - ConfigurationKeys.USERNAME_KEY: "test.username", - ConfigurationKeys.AUTHORITY_KEY: "https://authority.example.com", - ConfigurationKeys.IGNORE_SSL_ERRORS_KEY: "True", - } - return mock_config - - -@pytest.fixture -def config_parser(mocker): - mocks = ConfigParserMocks() - mocks.initializer = mocker.patch("configparser.ConfigParser.__init__") - mocks.item_setter = mocker.patch("configparser.ConfigParser.__setitem__") - mocks.item_getter = mocker.patch("configparser.ConfigParser.__getitem__") - mocks.section_adder = mocker.patch("configparser.ConfigParser.add_section") - mocks.reader = mocker.patch("configparser.ConfigParser.read") - mocks.sections = mocker.patch("configparser.ConfigParser.sections") - mocks.initializer.return_value = None - return mocks - @pytest.fixture def namespace(mocker): mock = mocker.MagicMock(spec=Namespace) + mock.profile_name = None mock.is_incremental = None mock.advanced_query = None mock.is_debug_mode = None mock.begin_date = None mock.end_date = None mock.exposure_types = None - mock.c42username = None - mock.actor = None - mock.md5 = None - mock.sha256 = None - mock.source = None - mock.filename = None - mock.filepath = None - mock.process_owner = None - mock.tab_url = None + mock.c42usernames = None + mock.actors = None + mock.md5_hashes = None + mock.sha256_hashes = None + mock.sources = None + mock.filenames = None + mock.filepaths = None + mock.process_owners = None + mock.tab_urls = None mock.include_non_exposure_events = None return mock -class ConfigParserMocks(object): - initializer = None - item_setter = None - item_getter = None - section_adder = None - reader = None - sections = None - - def get_filter_value_from_json(json, filter_index): return json_module.loads(str(json))["filters"][filter_index]["value"] diff --git a/tests/profile/test_config.py b/tests/profile/test_config.py index e206e43c6..f895c9515 100644 --- a/tests/profile/test_config.py +++ b/tests/profile/test_config.py @@ -1,141 +1,203 @@ from __future__ import with_statement -import pytest - -import code42cli.profile.config as config - - -class SharedConfigMocks(object): - mocker = None - open_function = None - path_exists_function = None - get_project_path_function = None - config_parser = None - - def setup_existing_config_file(self): - self.path_exists_function.return_value = True - sections = self.mocker.patch("configparser.ConfigParser.sections") - sections.return_value = [ - config.ConfigurationKeys.INTERNAL_SECTION, - config.ConfigurationKeys.USER_SECTION, - ] +from configparser import ConfigParser - def setup_non_existing_config_file(self): - self.path_exists_function.return_value = False - - def setup_existing_profile(self): - self.config_parser.item_getter.return_value = self._create_config_profile(is_set=True) - - def setup_non_existing_profile(self): - self.config_parser.item_getter.return_value = self._create_config_profile(is_set=False) +import pytest - def _create_config_profile(self, is_set): - config_profile = self.mocker.MagicMock() - config_profile.getboolean.return_value = is_set - bool_getter = self.mocker.MagicMock() - bool_getter.return_value = is_set - config_profile.getboolean = bool_getter - config_profile.__setitem__ = self.mocker.MagicMock() - return config_profile +from code42cli.profile.config import ConfigAccessor @pytest.fixture -def shared_config_mocks(mocker, config_parser): - # Project path - get_project_path_function = mocker.patch("code42cli.util.get_user_project_path") - get_project_path_function.return_value = "some/path/" - - # Opening files - open_file_function = mocker.patch("code42cli.util.open_file") - new_file = mocker.MagicMock() - open_file_function.return_value = new_file - - # Path exists - path_exists_function = mocker.patch("os.path.exists") - - mocks = SharedConfigMocks() - mocks.mocker = mocker - mocks.open_function = open_file_function - mocks.path_exists_function = path_exists_function - mocks.get_project_path_function = get_project_path_function - mocks.config_parser = config_parser - return mocks - - -def save_was_called(open_file_function): - call_args = open_file_function.call_args - try: - return call_args[0][0] == "some/path/config.cfg" and call_args[0][1] == "w+" - except: - return False - - -def test_get_config_profile_when_file_exists_but_profile_does_not_exist_exits(shared_config_mocks): - shared_config_mocks.setup_existing_config_file() - shared_config_mocks.setup_non_existing_profile() - - # It is expected to exit because the user must set their profile before they can see it. - with pytest.raises(SystemExit): - config.get_config_profile() - - -def test_get_config_profile_when_file_exists_and_profile_is_set_does_not_exit(shared_config_mocks): - shared_config_mocks.setup_existing_config_file() - shared_config_mocks.setup_existing_profile() - - # Presumably, it shows the profile instead of exiting. - assert config.get_config_profile() - - -def test_get_config_profile_when_file_does_not_exist_saves_changes(shared_config_mocks): - shared_config_mocks.setup_non_existing_config_file() - shared_config_mocks.setup_non_existing_profile() - - with pytest.raises(SystemExit): - config.get_config_profile() - - # It saves because it is writing default values to the config file - assert save_was_called(shared_config_mocks.open_function) - - -def test_profile_has_been_set_when_is_set_returns_true(shared_config_mocks): - shared_config_mocks.setup_existing_profile() - assert config.profile_has_been_set() - - -def test_profile_has_been_set_when_is_not_set_returns_false(shared_config_mocks): - shared_config_mocks.setup_non_existing_profile() - assert not config.profile_has_been_set() - - -def test_mark_as_set_if_complete_when_profile_is_set_but_not_marked_in_config_file_saves( - shared_config_mocks -): - shared_config_mocks.setup_existing_profile() - shared_config_mocks.setup_non_existing_config_file() - config.mark_as_set_if_complete() - assert save_was_called(shared_config_mocks.open_function) - - -def test_mark_as_set_if_complete_when_already_set_and_marked_in_config_file_does_not_save( - shared_config_mocks -): - shared_config_mocks.setup_existing_profile() - shared_config_mocks.setup_existing_config_file() - config.mark_as_set_if_complete() - assert not save_was_called(shared_config_mocks.open_function) - - -def test_set_username_saves(shared_config_mocks): - config.set_username("New user") - assert save_was_called(shared_config_mocks.open_function) - - -def test_set_authority_url_saves(shared_config_mocks): - config.set_authority_url("New url") - assert save_was_called(shared_config_mocks.open_function) - +def mock_config_parser(mocker): + return mocker.MagicMock(sepc=ConfigParser) -def test_set_ignore_ssl_errors_saves(shared_config_mocks): - config.set_ignore_ssl_errors(True) - assert save_was_called(shared_config_mocks.open_function) + +@pytest.fixture(autouse=True) +def mock_saver(mocker): + return mocker.patch("code42cli.util.open_file") + + +def create_mock_profile_object(name, authority=None, username=None): + authority = authority or ConfigAccessor.DEFAULT_VALUE + username = username or ConfigAccessor.DEFAULT_VALUE + profile_dict = {ConfigAccessor.AUTHORITY_KEY: authority, ConfigAccessor.USERNAME_KEY: username} + + class ProfileObject(object): + def __getitem__(self, item): + return profile_dict[item] + + def __setitem__(self, key, value): + profile_dict[key] = value + + def get(self, item): + return profile_dict.get(item) + + @property + def name(self): + return name + + return ProfileObject() + + +def create_internal_object(is_complete, default_profile_name=None): + default_profile_name = default_profile_name or ConfigAccessor.DEFAULT_VALUE + internal_dict = { + ConfigAccessor.DEFAULT_PROFILE: default_profile_name, + ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE: is_complete, + } + + class InternalObject(object): + def __getitem__(self, item): + return internal_dict[item] + + def __setitem__(self, key, value): + internal_dict[key] = value + + def getboolean(self, *args): + return is_complete + + return InternalObject() + + +def setup_parser_one_profile(profile, internal, parser): + def side_effect(item): + if item == "ProfileA": + return profile + elif item == "Internal": + return internal + + parser.__getitem__.side_effect = side_effect + + +class TestConfigAccessor(object): + def test_get_profile_when_profile_does_not_exist_raises(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal"] + accessor = ConfigAccessor(mock_config_parser) + with pytest.raises(Exception): + accessor.get_profile("Profile Name that does not exist") + + def test_get_profile_when_profile_has_default_name_raises(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal"] + accessor = ConfigAccessor(mock_config_parser) + with pytest.raises(Exception): + accessor.get_profile("__DEFAULT__") + + def test_get_profile_returns_expected_profile(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + accessor = ConfigAccessor(mock_config_parser) + accessor.get_profile("ProfileA") + assert mock_config_parser.__getitem__.call_args[0][0] == "ProfileA" + + def test_get_all_profiles_excludes_internal_section(self, mock_config_parser): + mock_config_parser.sections.return_value = ["ProfileA", "Internal", "ProfileB"] + accessor = ConfigAccessor(mock_config_parser) + profiles = accessor.get_all_profiles() + for p in profiles: + if p.name == "Internal": + assert False + + def test_set_username_marks_as_complete_if_ready(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile = create_mock_profile_object("ProfileA", "example.com", None) + mock_internal = create_internal_object(False) + setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) + accessor.set_username("TestUser", "ProfileA") + assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == "ProfileA" + assert mock_internal[ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE] + + def test_set_username_does_not_mark_as_complete_if_not_have_authority(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile = create_mock_profile_object("ProfileA", None, None) + mock_internal = create_internal_object(False) + setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) + accessor.set_username("TestUser", "ProfileA") + assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == ConfigAccessor.DEFAULT_VALUE + assert not mock_internal[ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE] + + def test_set_username_saves(self, mock_config_parser, mock_saver): + mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile = create_mock_profile_object("ProfileA", "example.com", "console.com") + mock_internal = create_internal_object(True, "ProfileA") + setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) + accessor.set_username("TestUser", "ProfileA") + assert mock_saver.call_count + + def test_set_authority_marks_as_complete_if_ready(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile = create_mock_profile_object("ProfileA", None, "test.testerson") + mock_internal = create_internal_object(False) + setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) + accessor.set_authority_url("new url", "ProfileA") + assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == "ProfileA" + assert mock_internal[ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE] + + def test_set_authority_does_not_mark_as_complete_if_not_have_username(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile = create_mock_profile_object("ProfileA", None, None) + mock_internal = create_internal_object(False) + setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) + accessor.set_authority_url("new url", "ProfileA") + assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == ConfigAccessor.DEFAULT_VALUE + assert not mock_internal[ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE] + + def test_set_authority_saves(self, mock_config_parser, mock_saver): + mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile = create_mock_profile_object("ProfileA", None, None) + mock_internal = create_internal_object(True, "ProfileA") + setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) + accessor.set_authority_url("new url", "ProfileA") + assert mock_saver.call_count + + def test_switch_default_profile_switches_internal_value(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile_a = create_mock_profile_object("ProfileA", "test", "test") + mock_profile_b = create_mock_profile_object("ProfileB", "test", "test") + + mock_internal = create_internal_object(True, "ProfileA") + + def side_effect(item): + if item == "ProfileA": + return mock_profile_a + elif item == "ProfileB": + return mock_profile_b + elif item == "Internal": + return mock_internal + + mock_config_parser.__getitem__.side_effect = side_effect + accessor.switch_default_profile("ProfileB") + assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == "ProfileB" + + def test_switch_default_profile_saves(self, mock_config_parser, mock_saver): + mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile_a = create_mock_profile_object("ProfileA", "test", "test") + mock_profile_b = create_mock_profile_object("ProfileB", "test", "test") + + mock_internal = create_internal_object(True, "ProfileA") + + def side_effect(item): + if item == "ProfileA": + return mock_profile_a + elif item == "ProfileB": + return mock_profile_b + elif item == "Internal": + return mock_internal + + mock_config_parser.__getitem__.side_effect = side_effect + accessor.switch_default_profile("ProfileB") + assert mock_saver.call_count + + def test_create_profile_if_not_exists_when_given_default_name_does_not_create( + self, mock_config_parser + ): + mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + accessor = ConfigAccessor(mock_config_parser) + with pytest.raises(Exception): + accessor.create_profile_if_not_exists(ConfigAccessor.DEFAULT_VALUE) diff --git a/tests/profile/test_password.py b/tests/profile/test_password.py index 00a53660c..2665f07fd 100644 --- a/tests/profile/test_password.py +++ b/tests/profile/test_password.py @@ -1,6 +1,24 @@ import pytest import code42cli.profile.password as password +from code42cli.profile.config import ConfigAccessor +from .conftest import PASSWORD_NAMESPACE + +_USERNAME = "test.username" + + +@pytest.fixture +def config_accessor(mocker): + mock = mocker.MagicMock(spec=ConfigAccessor) + factory = mocker.patch("{0}.get_config_accessor".format(PASSWORD_NAMESPACE)) + factory.return_value = mock + + class MockConfigProfile(object): + def __getitem__(self, item): + return _USERNAME + + mock.get_profile.return_value = MockConfigProfile() + return mock @pytest.fixture @@ -19,27 +37,26 @@ def getpass_function(mocker): def test_get_password_uses_expected_service_name_and_username( - keyring_password_getter, config_profile + keyring_password_getter, config_accessor ): - password.get_password() - expected_service_name = "code42cli::https://authority.example.com" - expected_username = "test.username" - keyring_password_getter.assert_called_once_with(expected_service_name, expected_username) + password.get_password("profile_name") + expected_service_name = "code42cli::profile_name" + keyring_password_getter.assert_called_once_with(expected_service_name, _USERNAME) def test_get_password_returns_expected_password( - keyring_password_getter, config_profile, keyring_password_setter + keyring_password_getter, config_accessor, keyring_password_setter ): keyring_password_getter.return_value = "already stored password 123" - assert password.get_password() == "already stored password 123" + assert password.get_password("profile_name") == "already stored password 123" def test_set_password_from_prompt_uses_expected_service_name_username_and_password( - keyring_password_setter, config_profile, getpass_function + keyring_password_setter, config_accessor, getpass_function ): getpass_function.return_value = "test password" - password.set_password_from_prompt() - expected_service_name = "code42cli::https://authority.example.com" + password.set_password_from_prompt("profile_name") + expected_service_name = "code42cli::profile_name" expected_username = "test.username" keyring_password_setter.assert_called_once_with( expected_service_name, expected_username, "test password" diff --git a/tests/profile/test_profile.py b/tests/profile/test_profile.py index d509529f3..563f9fe3d 100644 --- a/tests/profile/test_profile.py +++ b/tests/profile/test_profile.py @@ -3,27 +3,16 @@ import pytest from code42cli.profile import profile -from .conftest import CONFIG_NAMESPACE, PASSWORD_NAMESPACE, PROFILE_NAMESPACE +from code42cli.profile.config import ConfigAccessor +from .conftest import PASSWORD_NAMESPACE, PROFILE_NAMESPACE -@pytest.fixture(autouse=True) -def username_setter(mocker): - return mocker.patch("{0}.set_username".format(CONFIG_NAMESPACE)) - - -@pytest.fixture(autouse=True) -def mark_as_set_function(mocker): - return mocker.patch("{0}.mark_as_set_if_complete".format(CONFIG_NAMESPACE)) - - -@pytest.fixture(autouse=True) -def authority_url_setter(mocker): - return mocker.patch("{0}.set_authority_url".format(CONFIG_NAMESPACE)) - - -@pytest.fixture(autouse=True) -def ignore_ssl_errors_setter(mocker): - return mocker.patch("{0}.set_ignore_ssl_errors".format(CONFIG_NAMESPACE)) +@pytest.fixture +def config_accessor(mocker): + mock = mocker.MagicMock(spec=ConfigAccessor) + factory = mocker.patch("{0}.profile.get_config_accessor".format(PROFILE_NAMESPACE)) + factory.return_value = mock + return mock @pytest.fixture(autouse=True) @@ -47,34 +36,44 @@ def _get_profile_parser(): return subcommand_parser.choices.get("profile") +def create_profile(): + class MockSection(object): + name = "TEST" + + def get(*args): + pass + + return profile.Code42Profile(MockSection()) + + class TestCode42Profile(object): def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter): password_getter.return_value = None mock_getpass = mocker.patch("{0}.get_password_from_prompt".format(PASSWORD_NAMESPACE)) mock_getpass.return_value = "Test Password" - actual = profile.Code42Profile().get_password() + actual = create_profile().get_password() assert actual == "Test Password" def test_get_password_return_password_from_password_get_password(self, password_getter): password_getter.return_value = "Test Password" - actual = profile.Code42Profile().get_password() + actual = create_profile().get_password() assert actual == "Test Password" -def test_init_adds_profile_subcommand_to_choices(config_parser): +def test_init_adds_profile_subcommand_to_choices(config_accessor): subcommand_parser = ArgumentParser().add_subparsers() profile.init(subcommand_parser) assert subcommand_parser.choices.get("profile") -def test_init_adds_parser_that_can_parse_show_command(config_parser): +def test_init_adds_parser_that_can_parse_show_command(config_accessor): subcommand_parser = ArgumentParser().add_subparsers() profile.init(subcommand_parser) profile_parser = subcommand_parser.choices.get("profile") - assert profile_parser.parse_args(["show"]) + assert profile_parser.parse_args(["show", "--profile", "name"]) -def test_init_adds_parser_that_can_parse_set_command(config_parser): +def test_init_adds_parser_that_can_parse_set_command(config_accessor): subcommand_parser = ArgumentParser().add_subparsers() profile.init(subcommand_parser) profile_parser = subcommand_parser.choices.get("profile") @@ -83,61 +82,83 @@ def test_init_adds_parser_that_can_parse_set_command(config_parser): ) -def test_get_profile_returns_object_from_config_profile(config_parser, config_profile): +def test_init_add_parser_that_can_parse_list_command(): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + profile_parser = subcommand_parser.choices.get("profile") + assert profile_parser.parse_args(["list"]) + + +def test_init_add_parser_that_can_parse_use_command(): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + profile_parser = subcommand_parser.choices.get("profile") + assert profile_parser.parse_args(["use", "name"]) + + +def test_get_profile_returns_object_from_config_profile(mocker, config_accessor): + expected = mocker.MagicMock() + config_accessor.get_profile.return_value = expected user = profile.get_profile() - # Values from config_profile fixture - assert ( - user.username == "test.username" - and user.authority_url == "https://authority.example.com" - and user.ignore_ssl_errors - ) + assert user._profile == expected -def test_set_profile_when_given_username_sets_username(config_parser, username_setter): +def test_set_profile_when_given_username_sets_username(config_accessor): parser = _get_profile_parser() namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) profile.set_profile(namespace) - username_setter.assert_called_once_with("a.new.user@example.com") + assert config_accessor.set_username.call_args[0][0] == "a.new.user@example.com" -def test_set_profile_when_given_authority_url_sets_authority_url( - config_parser, authority_url_setter -): +def test_set_profile_when_given_profile_name_sets_username_for_profile(config_accessor): parser = _get_profile_parser() - namespace = parser.parse_args(["set", "-s", "https://wwww.new.authority.example.com"]) + namespace = parser.parse_args(["set", "--profile", "profileA", "-u", "a.new.user@example.com"]) profile.set_profile(namespace) - authority_url_setter.assert_called_once_with("https://wwww.new.authority.example.com") + assert config_accessor.set_username.call_args[0][0] == "a.new.user@example.com" + assert config_accessor.set_username.call_args[0][1] == "profileA" -def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true( - config_parser, ignore_ssl_errors_setter -): +def test_set_profile_when_given_authority_sets_authority(config_accessor): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "-s", "example.com"]) + profile.set_profile(namespace) + assert config_accessor.set_authority_url.call_args[0][0] == "example.com" + + +def test_set_profile_when_given_profile_name_sets_authority_for_profile(config_accessor): + parser = _get_profile_parser() + namespace = parser.parse_args(["set", "--profile", "profileA", "-s", "example.com"]) + profile.set_profile(namespace) + assert config_accessor.set_authority_url.call_args[0][0] == "example.com" + assert config_accessor.set_authority_url.call_args[0][1] == "profileA" + + +def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true(config_accessor): parser = _get_profile_parser() namespace = parser.parse_args(["set", "--enable-ssl-errors"]) profile.set_profile(namespace) - ignore_ssl_errors_setter.assert_called_once_with(False) + assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == False -def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_false( - config_parser, ignore_ssl_errors_setter -): +def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_true(config_accessor): parser = _get_profile_parser() namespace = parser.parse_args(["set", "--disable-ssl-errors"]) profile.set_profile(namespace) - ignore_ssl_errors_setter.assert_called_once_with(True) + assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == True -def test_set_profile_calls_marks_as_set_if_complete(config_parser, mark_as_set_function): +def test_set_profile_when_given_disable_ssl_errors_and_profile_name_sets_ignore_ssl_errors_to_true_for_profile( + config_accessor +): parser = _get_profile_parser() - namespace = parser.parse_args( - ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] - ) + namespace = parser.parse_args(["set", "--profile", "profileA", "--disable-ssl-errors"]) profile.set_profile(namespace) - assert mark_as_set_function.call_count + assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == True + assert config_accessor.set_ignore_ssl_errors.call_args[0][1] == "profileA" -def test_set_profile_when_told_to_store_password_prompts_for_storing_password( - mocker, input_function +def test_set_profile_when_to_store_password_prompts_for_storing_password( + mocker, config_accessor, input_function ): input_function.return_value = "y" mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") @@ -150,7 +171,7 @@ def test_set_profile_when_told_to_store_password_prompts_for_storing_password( def test_set_profile_when_told_to_store_password_using_capital_y_prompts_for_storing_password( - mocker, input_function + mocker, config_accessor, input_function ): input_function.return_value = "Y" mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") @@ -163,7 +184,7 @@ def test_set_profile_when_told_to_store_password_using_capital_y_prompts_for_sto def test_set_profile_when_told_not_to_store_password_prompts_for_storing_password( - mocker, input_function + mocker, config_accessor, input_function ): input_function.return_value = "n" mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") @@ -175,7 +196,8 @@ def test_set_profile_when_told_not_to_store_password_prompts_for_storing_passwor assert not mock_set_password_function.call_count -def test_prompt_for_password_reset_calls_password_set_password_from_prompt(mocker): +def test_prompt_for_password_reset_calls_password_set_password_from_prompt(mocker, namespace): + namespace.profile_name = "profile name" mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") - profile.prompt_for_password_reset() + profile.prompt_for_password_reset(namespace) assert mock_set_password_function.call_count diff --git a/tests/securitydata/conftest.py b/tests/securitydata/conftest.py index 265404562..28b64ff0d 100644 --- a/tests/securitydata/conftest.py +++ b/tests/securitydata/conftest.py @@ -1,3 +1,5 @@ +import pytest + from tests.conftest import get_test_date_str SECURITYDATA_NAMESPACE = "code42cli.securitydata" @@ -8,3 +10,8 @@ begin_date_tuple_with_time = (get_test_date_str(days_ago=89), "3:12:33") end_date_tuple = (get_test_date_str(days_ago=10),) end_date_tuple_with_time = (get_test_date_str(days_ago=10), "11:22:43") + + +@pytest.fixture(autouse=True) +def sqlite_connection(mocker): + return mocker.patch("sqlite3.connect") diff --git a/tests/securitydata/subcommands/test_clear_checkpoint.py b/tests/securitydata/subcommands/test_clear_checkpoint.py index 7dc664a78..5f5717cda 100644 --- a/tests/securitydata/subcommands/test_clear_checkpoint.py +++ b/tests/securitydata/subcommands/test_clear_checkpoint.py @@ -3,19 +3,41 @@ from code42cli.securitydata.subcommands import clear_checkpoint as clearer from ..conftest import SECURITYDATA_NAMESPACE -_CURSOR_STORE_PATH = "{0}.cursor_store".format(SECURITYDATA_NAMESPACE) +_CURSOR_STORE_NAMESPACE = "{0}.cursor_store".format(SECURITYDATA_NAMESPACE) @pytest.fixture def cursor_store(mocker): - mock_init = mocker.patch("{0}.FileEventCursorStore.__init__".format(_CURSOR_STORE_PATH)) + mock_init = mocker.patch("{0}.FileEventCursorStore.__init__".format(_CURSOR_STORE_NAMESPACE)) mock_init.return_value = None mock = mocker.MagicMock() - mock_new = mocker.patch("{0}.FileEventCursorStore.__new__".format(_CURSOR_STORE_PATH)) + mock_new = mocker.patch("{0}.FileEventCursorStore.__new__".format(_CURSOR_STORE_NAMESPACE)) mock_new.return_value = mock return mock -def test_clear_checkpoint_calls_cursor_store_reset(cursor_store): - clearer.clear_checkpoint() - assert cursor_store.reset.call_count == 1 +@pytest.fixture +def profile(mocker): + class MockProfile(object): + @property + def name(self): + return "AlreadySetProfileName" + + mock = mocker.patch( + "{0}.subcommands.clear_checkpoint.get_profile".format(SECURITYDATA_NAMESPACE) + ) + mock.return_value = MockProfile() + return mock + + +def test_clear_checkpoint_when_given_profile_name_calls_cursor_store_resets( + cursor_store, namespace +): + namespace.profile_name = "Test" + clearer.clear_checkpoint(namespace) + assert cursor_store.replace_stored_insertion_timestamp.call_args[0][0] is None + + +def test_clear_checkpoint_calls_cursor_store_resets(cursor_store, namespace, profile): + clearer.clear_checkpoint(namespace) + assert cursor_store.replace_stored_insertion_timestamp.call_args[0][0] is None diff --git a/tests/securitydata/subcommands/test_print_out.py b/tests/securitydata/subcommands/test_print_out.py index 60b632140..d99ce361b 100644 --- a/tests/securitydata/subcommands/test_print_out.py +++ b/tests/securitydata/subcommands/test_print_out.py @@ -3,12 +3,21 @@ import pytest import code42cli.securitydata.subcommands.print_out as printer +from code42cli.profile.config import ConfigAccessor from .conftest import ACCEPTABLE_ARGS from ..conftest import SUBCOMMANDS_NAMESPACE _PRINT_PATH = "{0}.print_out".format(SUBCOMMANDS_NAMESPACE) +@pytest.fixture +def config_accessor(mocker): + mock = mocker.MagicMock(spec=ConfigAccessor) + factory = mocker.patch("") + factory.return_value = mock + return mock + + @pytest.fixture def logger_factory(mocker): return mocker.patch("{0}.get_logger_for_stdout".format(_PRINT_PATH)) @@ -19,7 +28,7 @@ def extractor(mocker): return mocker.patch("{0}.extract".format(_PRINT_PATH)) -def test_init_adds_parser_that_can_parse_supported_args(config_parser): +def test_init_adds_parser_that_can_parse_supported_args(): subcommand_parser = ArgumentParser().add_subparsers() printer.init(subcommand_parser) print_parser = subcommand_parser.choices.get("print") diff --git a/tests/securitydata/subcommands/test_send_to.py b/tests/securitydata/subcommands/test_send_to.py index 27df08274..0ccea50c0 100644 --- a/tests/securitydata/subcommands/test_send_to.py +++ b/tests/securitydata/subcommands/test_send_to.py @@ -27,7 +27,7 @@ def extractor(mocker): return mocker.patch("{0}.extract".format(_SEND_PATH)) -def test_init_adds_parser_that_can_parse_supported_args(config_parser): +def test_init_adds_parser_that_can_parse_supported_args(): subcommand_parser = ArgumentParser().add_subparsers() sender.init(subcommand_parser) send_parser = subcommand_parser.choices.get("send-to") @@ -35,7 +35,7 @@ def test_init_adds_parser_that_can_parse_supported_args(config_parser): send_parser.parse_args(args) -def test_init_adds_parser_when_not_given_server_causes_system_exit(config_parser): +def test_init_adds_parser_when_not_given_server_causes_system_exit(): subcommand_parser = ArgumentParser().add_subparsers() sender.init(subcommand_parser) send_parser = subcommand_parser.choices.get("send-to") diff --git a/tests/securitydata/subcommands/test_write_to.py b/tests/securitydata/subcommands/test_write_to.py index 379846751..1abbe4771 100644 --- a/tests/securitydata/subcommands/test_write_to.py +++ b/tests/securitydata/subcommands/test_write_to.py @@ -26,7 +26,7 @@ def extractor(mocker): return mocker.patch("{0}.extract".format(_WRITE_PATH)) -def test_init_adds_parser_that_can_parse_supported_args(config_parser): +def test_init_adds_parser_that_can_parse_supported_args(): subcommand_parser = ArgumentParser().add_subparsers() writer.init(subcommand_parser) write_parser = subcommand_parser.choices.get("write-to") @@ -34,7 +34,7 @@ def test_init_adds_parser_that_can_parse_supported_args(config_parser): write_parser.parse_args(args) -def test_init_adds_parser_when_not_given_filename_causes_system_exit(config_parser): +def test_init_adds_parser_when_not_given_filename_causes_system_exit(): subcommand_parser = ArgumentParser().add_subparsers() writer.init(subcommand_parser) write_parser = subcommand_parser.choices.get("write-to") diff --git a/tests/securitydata/test_cursor_store.py b/tests/securitydata/test_cursor_store.py index 3d40161b9..37f2732a4 100644 --- a/tests/securitydata/test_cursor_store.py +++ b/tests/securitydata/test_cursor_store.py @@ -1,16 +1,10 @@ from os import path -import pytest from c42eventextractor.extractors import INSERTION_TIMESTAMP_FIELD_NAME from code42cli.securitydata.cursor_store import BaseCursorStore, FileEventCursorStore -@pytest.fixture -def sqlite_connection(mocker): - return mocker.patch("sqlite3.connect") - - class TestBaseCursorStore(object): def test_init_cursor_store_when_not_given_db_file_path_uses_expected_path_with_db_table_name_as_db_file_name( self, sqlite_connection @@ -29,47 +23,23 @@ def test_init_cursor_store_when_given_db_file_path_uses_given_path(self, sqlite_ class TestFileEventCursorStore(object): - MOCK_TEST_DB_PATH = "test_path.db" - - def test_reset_executes_expected_drop_table_query(self, sqlite_connection): - store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) - store.reset() - with store._connection as conn: - actual = conn.execute.call_args_list[0][0][0] - expected = "DROP TABLE aed_checkpoint" - assert actual == expected - - def test_reset_executes_expected_create_table_query(self, sqlite_connection): - store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) - store.reset() - with store._connection as conn: - actual = conn.execute.call_args_list[1][0][0] - expected = "CREATE TABLE aed_checkpoint (cursor_id, insertionTimestamp)" - assert actual == expected + MOCK_TEST_DB_NAME = "test_path.db" - def test_reset_executes_expected_insert_query(self, sqlite_connection): - store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) - store._connection = sqlite_connection - store.reset() - with store._connection as conn: - actual = conn.execute.call_args[0][0] - expected = "INSERT INTO aed_checkpoint VALUES(?, null)" - assert actual == expected - - def test_reset_executes_query_with_expected_primary_key(self, sqlite_connection): - store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) - store._connection = sqlite_connection - store.reset() - with store._connection as conn: - actual = conn.execute.call_args[0][1][0] - expected = store._PRIMARY_KEY - assert actual == expected + def test_init_when_called_twice_with_different_profile_names_creates_two_rows( + self, mocker, sqlite_connection + ): + mock = mocker.patch("code42cli.securitydata.cursor_store.FileEventCursorStore._row_exists") + mock.return_value = False + spy = mocker.spy(FileEventCursorStore, "_insert_new_row") + FileEventCursorStore("Profile A", self.MOCK_TEST_DB_NAME) + FileEventCursorStore("Profile B", self.MOCK_TEST_DB_NAME) + assert spy.call_count == 2 def test_get_stored_insertion_timestamp_executes_expected_select_query(self, sqlite_connection): - store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) store.get_stored_insertion_timestamp() with store._connection as conn: - expected = "SELECT {0} FROM aed_checkpoint WHERE cursor_id=?".format( + expected = "SELECT {0} FROM file_event_checkpoints WHERE cursor_id=?".format( INSERTION_TIMESTAMP_FIELD_NAME ) actual = conn.cursor().execute.call_args[0][0] @@ -78,20 +48,20 @@ def test_get_stored_insertion_timestamp_executes_expected_select_query(self, sql def test_get_stored_insertion_timestamp_executes_query_with_expected_primary_key( self, sqlite_connection ): - store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) store.get_stored_insertion_timestamp() with store._connection as conn: actual = conn.cursor().execute.call_args[0][1][0] - expected = store._PRIMARY_KEY + expected = store._primary_key assert actual == expected def test_replace_stored_insertion_timestamp_executes_expected_update_query( self, sqlite_connection ): - store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) store.replace_stored_insertion_timestamp(123) with store._connection as conn: - expected = "UPDATE aed_checkpoint SET {0}=? WHERE cursor_id=?".format( + expected = "UPDATE file_event_checkpoints SET {0}=? WHERE cursor_id=?".format( INSERTION_TIMESTAMP_FIELD_NAME ) actual = conn.execute.call_args[0][0] @@ -100,7 +70,7 @@ def test_replace_stored_insertion_timestamp_executes_expected_update_query( def test_replace_stored_insertion_timestamp_executes_query_with_expected_primary_key( self, sqlite_connection ): - store = FileEventCursorStore(self.MOCK_TEST_DB_PATH) + store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) new_insertion_timestamp = 123 store.replace_stored_insertion_timestamp(new_insertion_timestamp) with store._connection as conn: diff --git a/tests/securitydata/test_date_helper.py b/tests/securitydata/test_date_helper.py index 4dab3a502..f57e8b44c 100644 --- a/tests/securitydata/test_date_helper.py +++ b/tests/securitydata/test_date_helper.py @@ -61,11 +61,6 @@ def test_create_event_timestamp_filter_when_end_is_before_begin_causes_value_err create_event_timestamp_filter(begin_date_tuple, end_date_str) -def test_create_event_timestamp_filter_when_given_minutes_ago_and_time_raises_value_error(): - with pytest.raises(ValueError): - create_event_timestamp_filter("600", "12:00:00") - - def test_create_event_timestamp_filter_when_given_three_date_args_raises_value_error(): begin_date_tuple = (get_test_date_str(days_ago=5), "12:00:00", "end_date=12:00:00") with pytest.raises(ValueError): diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index b71deb03f..504b6db2b 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -89,63 +89,63 @@ def test_extract_when_is_advanced_query_and_has_exposure_types_exits(logger, nam def test_extract_when_is_advanced_query_and_has_username_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.c42username = "Someone" + namespace.c42usernames = ["Someone"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_actor_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.actor = "Someone" + namespace.actors = ["Someone"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_md5_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.md5 = "098f6bcd4621d373cade4e832627b4f6" + namespace.md5_hashes = ["098f6bcd4621d373cade4e832627b4f6"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_sha256_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + namespace.sha256_hashes = ["9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_source_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.source = "Gmail" + namespace.sources = ["Gmail"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_filename_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.filename = "test.out" + namespace.filenames = ["test.out"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_filepath_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.filepath = "path/to/file" + namespace.filepaths = ["path/to/file"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_process_owner_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.process_owner = "someone" + namespace.process_owners = ["someone"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_is_advanced_query_and_has_tab_url_exists(logger, namespace): namespace.advanced_query = "some complex json" - namespace.tab_url = "https://www.example.com" + namespace.tab_urls = ["https://www.example.com"] with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) @@ -164,6 +164,14 @@ def test_extract_when_is_advanced_query_and_has_include_non_exposure_exits(logge extraction_module.extract(logger, namespace) +def test_extract_when_is_advanced_query_and_include_non_exposure_is_false_does_not_exit( + logger, namespace +): + namespace.include_non_exposure_events = False + namespace.advanced_query = "some complex json" + extraction_module.extract(logger, namespace) + + def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( logger, namespace ): @@ -327,65 +335,75 @@ def test_extract_when_given_end_date_with_len_3_causes_exit( def test_extract_when_given_username_uses_username_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.c42username = "test.testerson@example.com" + namespace_with_begin.c42usernames = ["test.testerson@example.com"] extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( - DeviceUsername.eq(namespace_with_begin.c42username) + DeviceUsername.is_in(namespace_with_begin.c42usernames) ) def test_extract_when_given_actor_uses_actor_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.actor = "test.testerson" + namespace_with_begin.actors = ["test.testerson"] extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(Actor.eq(namespace_with_begin.actor)) + assert str(extractor.extract.call_args[0][1]) == str(Actor.is_in(namespace_with_begin.actors)) def test_extract_when_given_md5_uses_md5_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.md5 = "098f6bcd4621d373cade4e832627b4f6" + namespace_with_begin.md5_hashes = ["098f6bcd4621d373cade4e832627b4f6"] extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(MD5.eq(namespace_with_begin.md5)) + assert str(extractor.extract.call_args[0][1]) == str(MD5.is_in(namespace_with_begin.md5_hashes)) def test_extract_when_given_sha256_uses_sha256_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.sha256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + namespace_with_begin.sha256_hashes = [ + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ] extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(SHA256.eq(namespace_with_begin.sha256)) + assert str(extractor.extract.call_args[0][1]) == str( + SHA256.is_in(namespace_with_begin.sha256_hashes) + ) def test_extract_when_given_source_uses_source_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.source = "Gmail" + namespace_with_begin.sources = ["Gmail", "Yahoo"] extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(Source.eq(namespace_with_begin.source)) + assert str(extractor.extract.call_args[0][1]) == str(Source.is_in(namespace_with_begin.sources)) def test_extract_when_given_filename_uses_filename_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.filename = "file.txt" + namespace_with_begin.filenames = ["file.txt", "txt.file"] extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(FileName.eq(namespace_with_begin.filename)) + assert str(extractor.extract.call_args[0][1]) == str( + FileName.is_in(namespace_with_begin.filenames) + ) def test_extract_when_given_filepath_uses_filepath_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.filepath = "/path/to/file.txt" + namespace_with_begin.filepaths = ["/path/to/file.txt", "path2"] extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(FilePath.eq(namespace_with_begin.filepath)) + assert str(extractor.extract.call_args[0][1]) == str( + FilePath.is_in(namespace_with_begin.filepaths) + ) def test_extract_when_given_process_owner_uses_process_owner_filter( logger, namespace_with_begin, extractor ): - namespace_with_begin.process_owner = "test.testerson" + namespace_with_begin.process_owners = ["test.testerson", "another"] extraction_module.extract(logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( - ProcessOwner.eq(namespace_with_begin.process_owner) + ProcessOwner.is_in(namespace_with_begin.process_owners) ) def test_extract_when_given_tab_url_uses_process_tab_url_filter( logger, namespace_with_begin, extractor ): - namespace_with_begin.tab_url = "https://www.example.com" + namespace_with_begin.tab_urls = ["https://www.example.com"] extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(TabURL.eq(namespace_with_begin.tab_url)) + assert str(extractor.extract.call_args[0][1]) == str( + TabURL.is_in(namespace_with_begin.tab_urls) + ) def test_extract_when_given_exposure_types_uses_exposure_type_is_in_filter( @@ -418,13 +436,19 @@ def test_extract_when_not_given_include_non_exposure_includes_exposure_type_exis def test_extract_when_given_multiple_search_args_uses_expected_filters( logger, namespace_with_begin, extractor ): - namespace_with_begin.filepath = "/path/to/file.txt" - namespace_with_begin.process_owner = "test.testerson" - namespace_with_begin.tab_url = "https://www.example.com" + namespace_with_begin.filepaths = ["/path/to/file.txt"] + namespace_with_begin.process_owners = ["test.testerson", "flag.flagerson"] + namespace_with_begin.tab_urls = ["https://www.example.com"] extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(FilePath.eq("/path/to/file.txt")) - assert str(extractor.extract.call_args[0][2]) == str(ProcessOwner.eq("test.testerson")) - assert str(extractor.extract.call_args[0][3]) == str(TabURL.eq("https://www.example.com")) + assert str(extractor.extract.call_args[0][1]) == str( + FilePath.is_in(namespace_with_begin.filepaths) + ) + assert str(extractor.extract.call_args[0][2]) == str( + ProcessOwner.is_in(namespace_with_begin.process_owners) + ) + assert str(extractor.extract.call_args[0][3]) == str( + TabURL.is_in(namespace_with_begin.tab_urls) + ) def test_extract_when_given_include_non_exposure_and_exposure_types_causes_exit( From 0de1f290e10f3a149d7fa789700a0f35a4828699 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 13 Mar 2020 11:50:55 -0500 Subject: [PATCH 013/349] Bugfix/defaultp not used (#15) --- CHANGELOG.md | 13 ++ src/code42cli/__version__.py | 2 +- src/code42cli/profile/password.py | 32 ++-- src/code42cli/profile/profile.py | 26 ++- src/code42cli/sdk_client.py | 28 +++ .../securitydata/arguments/search.py | 2 - src/code42cli/securitydata/date_helper.py | 18 +- src/code42cli/securitydata/extraction.py | 30 +-- src/code42cli/securitydata/logger_factory.py | 4 +- src/code42cli/util.py | 4 +- tests/conftest.py | 43 +++++ tests/profile/test_config.py | 69 +++---- tests/profile/test_password.py | 43 +++-- tests/profile/test_profile.py | 171 +++++++++++++----- tests/securitydata/conftest.py | 13 +- .../subcommands/test_print_out.py | 9 - tests/securitydata/test_date_helper.py | 40 ++-- tests/securitydata/test_extraction.py | 61 +++---- tests/test_sdk_client.py | 46 +++++ 19 files changed, 432 insertions(+), 222 deletions(-) create mode 100644 src/code42cli/sdk_client.py create mode 100644 tests/test_sdk_client.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 578c9f488..76a568f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 0.4.1 - 2020-03-13 + +### Fixed + +- Bug where `profile reset-pw` did not work with the default profile. +- Bug where `profile show` indicated a password was set for a different profile. +- We now validate credentials when setting a password. + + +### Changed + +- Date inputs are now required to be in quotes when they include a time. + ## 0.4.0 - 2020-03-12 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 6a9beea82..3d26edf77 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.4.1" diff --git a/src/code42cli/profile/password.py b/src/code42cli/profile/password.py index b08c85b0e..ec2b65c3f 100644 --- a/src/code42cli/profile/password.py +++ b/src/code42cli/profile/password.py @@ -9,30 +9,32 @@ _ROOT_SERVICE_NAME = u"code42cli" -def get_password(profile_name): - """Gets your currently stored password for your profile.""" - accessor = get_config_accessor() - profile = accessor.get_profile(profile_name) - service_name = _get_service_name(profile_name) +def get_stored_password(profile_name): + """Gets your currently stored password for the given profile name.""" + profile = _get_profile(profile_name) + service_name = _get_service_name(profile.name) username = _get_username(profile) password = keyring.get_password(service_name, username) return password -def set_password_from_prompt(profile_name): - """Prompts and sets your password for your profile.""" - password = getpass() - accessor = get_config_accessor() - profile = accessor.get_profile(profile_name) - service_name = _get_service_name(profile_name) +def get_password_from_prompt(): + """Prompts you and returns what you input.""" + return getpass() + + +def set_password(profile_name, new_password): + """Sets your password for the given profile name.""" + profile = _get_profile(profile_name) + service_name = _get_service_name(profile.name) username = _get_username(profile) - keyring.set_password(service_name, username, password) + keyring.set_password(service_name, username, new_password) print(u"'Code42 Password' updated.") - return password -def get_password_from_prompt(): - return getpass() +def _get_profile(profile_name): + accessor = get_config_accessor() + return accessor.get_profile(profile_name) def _get_service_name(profile_name): diff --git a/src/code42cli/profile/profile.py b/src/code42cli/profile/profile.py index 2c06d0082..0639c66e7 100644 --- a/src/code42cli/profile/profile.py +++ b/src/code42cli/profile/profile.py @@ -10,6 +10,7 @@ print_set_profile_help, print_no_existing_profile_message, ) +from code42cli.sdk_client import validate_connection class Code42Profile(object): @@ -33,7 +34,7 @@ def ignore_ssl_error(self): return self._profile[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] def get_password(self): - pwd = password.get_password(self.name) + pwd = password.get_stored_password(self.name) if not pwd: pwd = password.get_password_from_prompt() return pwd @@ -91,7 +92,7 @@ def show_profile(args): print(u"\t* {0} = {1}".format(ConfigAccessor.USERNAME_KEY, profile.username)) print(u"\t* {0} = {1}".format(ConfigAccessor.AUTHORITY_KEY, profile.authority_url)) print(u"\t* {0} = {1}".format(ConfigAccessor.IGNORE_SSL_ERRORS_KEY, profile.ignore_ssl_error)) - if password.get_password(args.profile_name) is not None: + if password.get_stored_password(profile.name) is not None: print(u"\t* A password is set.") print(u"") @@ -109,7 +110,15 @@ def set_profile(args): def prompt_for_password_reset(args): """Securely prompts for your password and then stores it using keyring.""" - password.set_password_from_prompt(args.profile_name) + profile = get_profile(args.profile_name) + new_password = password.get_password_from_prompt() + if not validate_connection(profile.authority_url, profile.username, new_password): + print_error( + "Your password was not saved because your credentials failed to validate. " + "Check your network connection and the spelling of your username and server URL." + ) + exit(1) + password.set_password(profile.name, new_password) def list_profiles(*args): @@ -130,7 +139,7 @@ def use_profile(args): try: accessor.switch_default_profile(args.profile_name) except Exception as ex: - print_error(ex) + print_error(str(ex)) exit(1) @@ -198,9 +207,10 @@ def _verify_args_for_set(args): missing_values = not args.c42_username and not args.c42_authority_url if missing_values: try: - profile = get_profile(args.profile_name) + accessor = get_config_accessor() + profile = Code42Profile(accessor.get_profile(args.profile_name)) missing_values = not profile.username and not profile.authority_url - except SystemExit: + except Exception: missing_values = True if missing_values: @@ -231,10 +241,10 @@ def _missing_default_profile(args): profile_name_arg_is_none = ( args.profile_name is None or args.profile_name == ConfigAccessor.DEFAULT_VALUE ) - return profile_name_arg_is_none and not _default_profile_exists() + return profile_name_arg_is_none and not _default_profile_exist() -def _default_profile_exists(): +def _default_profile_exist(): try: accessor = get_config_accessor() profile = Code42Profile(accessor.get_profile()) diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py new file mode 100644 index 000000000..875edfc66 --- /dev/null +++ b/src/code42cli/sdk_client.py @@ -0,0 +1,28 @@ +from py42 import debug_level +from py42 import settings +from py42.sdk import SDK + +from code42cli.util import print_error + + +def create_sdk(profile, is_debug_mode): + if is_debug_mode: + settings.debug_level = debug_level.DEBUG + try: + password = profile.get_password() + return SDK.create_using_local_account(profile.authority_url, profile.username, password) + except Exception: + print_error( + u"Invalid credentials or host address. " + u"Verify your profile is set up correctly and that you are supplying the correct password." + ) + exit(1) + + +def validate_connection(authority_url, username, password): + try: + SDK.create_using_local_account(authority_url, username, password) + return True + except: + print(username, password, authority_url) + return False diff --git a/src/code42cli/securitydata/arguments/search.py b/src/code42cli/securitydata/arguments/search.py index b10c2a071..11523566e 100644 --- a/src/code42cli/securitydata/arguments/search.py +++ b/src/code42cli/securitydata/arguments/search.py @@ -70,7 +70,6 @@ def _add_begin_date_arg(parser): parser.add_argument( u"-b", u"--begin", - nargs=u"+", action=u"store", dest=SearchArguments.BEGIN_DATE, help=u"The beginning of the date range in which to look for events, " @@ -82,7 +81,6 @@ def _add_end_date_arg(parser): parser.add_argument( u"-e", u"--end", - nargs=u"+", action=u"store", dest=SearchArguments.END_DATE, help=u"The end of the date range in which to look for events, " diff --git a/src/code42cli/securitydata/date_helper.py b/src/code42cli/securitydata/date_helper.py index 07fd85900..6a0581a36 100644 --- a/src/code42cli/securitydata/date_helper.py +++ b/src/code42cli/securitydata/date_helper.py @@ -63,24 +63,24 @@ def _verify_timestamp_order(min_timestamp, max_timestamp): raise ValueError(u"Begin date cannot be after end date") -def _parse_timestamp(date_tuple): +def _parse_timestamp(date_and_time): try: - date_str = _join_date_tuple(date_tuple) - date_format = u"%Y-%m-%d" if len(date_tuple) == 1 else u"%Y-%m-%d %H:%M:%S" + date_str = _join_date_and_time(date_and_time) + date_format = u"%Y-%m-%d" if len(date_and_time) == 1 else u"%Y-%m-%d %H:%M:%S" time = datetime.strptime(date_str, date_format) except ValueError: raise ValueError(_FORMAT_VALUE_ERROR_MESSAGE) return convert_datetime_to_timestamp(time) -def _join_date_tuple(date_tuple): - if not date_tuple: +def _join_date_and_time(date_and_time): + if not date_and_time: return None - date_str = date_tuple[0] - if len(date_tuple) == 1: + date_str = date_and_time[0] + if len(date_and_time) == 1: return date_str - if len(date_tuple) == 2: - date_str = "{0} {1}".format(date_str, date_tuple[1]) + if len(date_and_time) == 2: + date_str = "{0} {1}".format(date_str, date_and_time[1]) else: raise ValueError(_FORMAT_VALUE_ERROR_MESSAGE) return date_str diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index ed2a95d06..203269801 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -4,9 +4,6 @@ from c42eventextractor import FileEventHandlers from c42eventextractor.extractors import FileEventExtractor -from py42 import debug_level -from py42 import settings -from py42.sdk import SDK from py42.sdk.file_event_query.cloud_query import Actor from py42.sdk.file_event_query.device_query import DeviceUsername from py42.sdk.file_event_query.event_query import Source @@ -22,6 +19,7 @@ from code42cli.securitydata.logger_factory import get_error_logger from code42cli.securitydata.options import ExposureType as ExposureTypeOptions from code42cli.util import print_error, print_bold, is_interactive +from code42cli.sdk_client import create_sdk _EXCEPTIONS_OCCURRED = False @@ -41,7 +39,7 @@ def extract(output_logger, args): store = _create_cursor_store(args, profile) filters = _get_filters(args, store) handlers = _create_event_handlers(output_logger, store) - sdk = _get_sdk(profile, args.is_debug_mode) + sdk = create_sdk(profile, args.is_debug_mode) extractor = FileEventExtractor(sdk, handlers) _call_extract(extractor, filters, args) _handle_result() @@ -85,12 +83,12 @@ def _verify_begin_date_requirements(args, cursor_store): def _begin_date_is_required(args, cursor_store): if not args.is_incremental: return True - required = cursor_store is not None and cursor_store.get_stored_insertion_timestamp() is None + is_required = cursor_store and cursor_store.get_stored_insertion_timestamp() is None # Ignore begin date when is incremental mode, it is not required, and it was passed an argument. - if not required and args.begin_date: + if not is_required and args.begin_date: args.begin_date = None - return required + return is_required def _verify_exposure_types(exposure_types): @@ -122,7 +120,9 @@ def _create_filters(args): def _get_event_timestamp_filter(args): try: - return date_helper.create_event_timestamp_filter(args.begin_date, args.end_date) + begin_date = args.begin_date.strip().split(" ") if args.begin_date else None + end_date = args.end_date.strip().split(" ") if args.end_date else None + return date_helper.create_event_timestamp_filter(begin_date, end_date) except ValueError as ex: print_error(str(ex)) exit(1) @@ -153,20 +153,6 @@ def handle_response(response): return handlers -def _get_sdk(profile, is_debug_mode): - if is_debug_mode: - settings.debug_level = debug_level.DEBUG - try: - password = profile.get_password() - return SDK.create_using_local_account(profile.authority_url, profile.username, password) - except Exception: - print_error( - u"Invalid credentials or host address. " - u"Verify your profile is set up correctly and that you are supplying the correct password." - ) - exit(1) - - def _call_extract(extractor, filters, args): if args.advanced_query: extractor.extract_advanced(args.advanced_query) diff --git a/src/code42cli/securitydata/logger_factory.py b/src/code42cli/securitydata/logger_factory.py index 3bcad6228..e1c91fea1 100644 --- a/src/code42cli/securitydata/logger_factory.py +++ b/src/code42cli/securitydata/logger_factory.py @@ -47,7 +47,7 @@ def get_logger_for_file(filename, output_format): with _logger_deps_lock: if not _logger_has_handlers(logger): - handler = logging.FileHandler(filename, delay=True) + handler = logging.FileHandler(filename, delay=True, encoding="utf-8") return _init_logger(logger, handler, output_format) return logger @@ -86,7 +86,7 @@ def get_error_logger(): with _logger_deps_lock: if not _logger_has_handlers(logger): formatter = logging.Formatter(u"%(asctime)s %(message)s") - handler = RotatingFileHandler(log_path, maxBytes=250000000) + handler = RotatingFileHandler(log_path, maxBytes=250000000, encoding="utf-8") return _apply_logger_dependencies(logger, handler, formatter) return logger diff --git a/src/code42cli/util.py b/src/code42cli/util.py index ae107ea91..81fb48136 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -27,13 +27,13 @@ def get_user_project_path(subdir=""): def open_file(file_path, mode, action): """Wrapper for opening files, useful for testing purposes.""" - with open(file_path, mode) as f: + with open(file_path, mode, encoding="utf-8") as f: action(f) def print_error(error_text): """Prints red text.""" - print("\033[91mUSAGE ERROR: {}\033[0m".format(error_text)) + print("\033[91mERROR: {}\033[0m".format(error_text)) def print_bold(bold_text): diff --git a/tests/conftest.py b/tests/conftest.py index 7c3df3e6b..d153fab27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,9 @@ import pytest +from code42cli.profile.config import ConfigAccessor +from code42cli.profile.profile import Code42Profile + @pytest.fixture def namespace(mocker): @@ -28,6 +31,46 @@ def namespace(mocker): return mock +def create_profile_values_dict(authority=None, username=None, ignore_ssl=False): + return { + ConfigAccessor.AUTHORITY_KEY: authority, + ConfigAccessor.USERNAME_KEY: username, + ConfigAccessor.IGNORE_SSL_ERRORS_KEY: ignore_ssl, + } + + +class MockSection(object): + def __init__(self, name="Test Profile Name", values_dict=None): + self.name = name + self.values_dict = values_dict or create_profile_values_dict() + + def __getitem__(self, item): + return self.values_dict[item] + + def __setitem__(self, key, value): + self.values_dict[key] = value + + def get(self, item): + return self.values_dict.get(item) + + +def create_mock_profile(name=None): + profile_section = MockSection(name) + profile = Code42Profile(profile_section) + + def mock_get_password(): + return "Test Password" + + profile.get_password = mock_get_password + return profile + + +def setup_mock_accessor(mock_accessor, name=None, values_dict=None): + profile_section = MockSection(name, values_dict) + mock_accessor.get_profile.return_value = profile_section + return mock_accessor + + def get_filter_value_from_json(json, filter_index): return json_module.loads(str(json))["filters"][filter_index]["value"] diff --git a/tests/profile/test_config.py b/tests/profile/test_config.py index f895c9515..b9cfe688d 100644 --- a/tests/profile/test_config.py +++ b/tests/profile/test_config.py @@ -1,10 +1,10 @@ from __future__ import with_statement -from configparser import ConfigParser - import pytest +from configparser import ConfigParser from code42cli.profile.config import ConfigAccessor +from ..conftest import MockSection @pytest.fixture @@ -17,26 +17,11 @@ def mock_saver(mocker): return mocker.patch("code42cli.util.open_file") -def create_mock_profile_object(name, authority=None, username=None): - authority = authority or ConfigAccessor.DEFAULT_VALUE - username = username or ConfigAccessor.DEFAULT_VALUE - profile_dict = {ConfigAccessor.AUTHORITY_KEY: authority, ConfigAccessor.USERNAME_KEY: username} - - class ProfileObject(object): - def __getitem__(self, item): - return profile_dict[item] - - def __setitem__(self, key, value): - profile_dict[key] = value - - def get(self, item): - return profile_dict.get(item) - - @property - def name(self): - return name - - return ProfileObject() +def create_mock_profile_object(profile_name, authority_url=None, username=None): + mock_profile = MockSection(profile_name) + mock_profile[ConfigAccessor.AUTHORITY_KEY] = authority_url + mock_profile[ConfigAccessor.USERNAME_KEY] = username + return mock_profile def create_internal_object(is_complete, default_profile_name=None): @@ -45,18 +30,13 @@ def create_internal_object(is_complete, default_profile_name=None): ConfigAccessor.DEFAULT_PROFILE: default_profile_name, ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE: is_complete, } + internal_section = MockSection("Internal", internal_dict) - class InternalObject(object): - def __getitem__(self, item): - return internal_dict[item] - - def __setitem__(self, key, value): - internal_dict[key] = value + def getboolean(*args): + return is_complete - def getboolean(self, *args): - return is_complete - - return InternalObject() + internal_section.getboolean = getboolean + return internal_section def setup_parser_one_profile(profile, internal, parser): @@ -99,7 +79,7 @@ def test_get_all_profiles_excludes_internal_section(self, mock_config_parser): def test_set_username_marks_as_complete_if_ready(self, mock_config_parser): mock_config_parser.sections.return_value = ["Internal", "ProfileA"] accessor = ConfigAccessor(mock_config_parser) - mock_profile = create_mock_profile_object("ProfileA", "example.com", None) + mock_profile = create_mock_profile_object("ProfileA", "www.example.com", None) mock_internal = create_internal_object(False) setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) accessor.set_username("TestUser", "ProfileA") @@ -119,7 +99,7 @@ def test_set_username_does_not_mark_as_complete_if_not_have_authority(self, mock def test_set_username_saves(self, mock_config_parser, mock_saver): mock_config_parser.sections.return_value = ["Internal", "ProfileA"] accessor = ConfigAccessor(mock_config_parser) - mock_profile = create_mock_profile_object("ProfileA", "example.com", "console.com") + mock_profile = create_mock_profile_object("ProfileA", "www.example.com", "username") mock_internal = create_internal_object(True, "ProfileA") setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) accessor.set_username("TestUser", "ProfileA") @@ -154,6 +134,27 @@ def test_set_authority_saves(self, mock_config_parser, mock_saver): accessor.set_authority_url("new url", "ProfileA") assert mock_saver.call_count + def test_get_all_profiles_returns_profiles_with_expected_values(self, mock_config_parser): + mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile_a = create_mock_profile_object("ProfileA", "test", "test") + mock_profile_b = create_mock_profile_object("ProfileB", "test", "test") + + mock_internal = create_internal_object(True, "ProfileA") + + def side_effect(item): + if item == "ProfileA": + return mock_profile_a + elif item == "ProfileB": + return mock_profile_b + elif item == "Internal": + return mock_internal + + mock_config_parser.__getitem__.side_effect = side_effect + profiles = accessor.get_all_profiles() + assert profiles[0].name == "ProfileA" + assert profiles[1].name == "ProfileB" + def test_switch_default_profile_switches_internal_value(self, mock_config_parser): mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] accessor = ConfigAccessor(mock_config_parser) diff --git a/tests/profile/test_password.py b/tests/profile/test_password.py index 2665f07fd..a44de41a9 100644 --- a/tests/profile/test_password.py +++ b/tests/profile/test_password.py @@ -3,6 +3,7 @@ import code42cli.profile.password as password from code42cli.profile.config import ConfigAccessor from .conftest import PASSWORD_NAMESPACE +from ..conftest import setup_mock_accessor, create_profile_values_dict _USERNAME = "test.username" @@ -12,12 +13,6 @@ def config_accessor(mocker): mock = mocker.MagicMock(spec=ConfigAccessor) factory = mocker.patch("{0}.get_config_accessor".format(PASSWORD_NAMESPACE)) factory.return_value = mock - - class MockConfigProfile(object): - def __getitem__(self, item): - return _USERNAME - - mock.get_profile.return_value = MockConfigProfile() return mock @@ -36,28 +31,42 @@ def getpass_function(mocker): return mocker.patch("code42cli.profile.password.getpass") -def test_get_password_uses_expected_service_name_and_username( +def test_get_stored_password_when_given_profile_name_gets_profile_for_that_name( keyring_password_getter, config_accessor ): - password.get_password("profile_name") - expected_service_name = "code42cli::profile_name" - keyring_password_getter.assert_called_once_with(expected_service_name, _USERNAME) + password.get_stored_password("profile_name") + config_accessor.get_profile.assert_called_once_with("profile_name") -def test_get_password_returns_expected_password( +def test_get_stored_password_returns_expected_password( keyring_password_getter, config_accessor, keyring_password_setter ): keyring_password_getter.return_value = "already stored password 123" - assert password.get_password("profile_name") == "already stored password 123" + assert password.get_stored_password("profile_name") == "already stored password 123" -def test_set_password_from_prompt_uses_expected_service_name_username_and_password( - keyring_password_setter, config_accessor, getpass_function +def test_set_password_uses_expected_service_name_username_and_password( + keyring_password_setter, config_accessor ): - getpass_function.return_value = "test password" - password.set_password_from_prompt("profile_name") + values = create_profile_values_dict(username="test.username") + setup_mock_accessor(config_accessor, "profile_name", values) + password.set_password("profile_name", "test_password") expected_service_name = "code42cli::profile_name" expected_username = "test.username" keyring_password_setter.assert_called_once_with( - expected_service_name, expected_username, "test password" + expected_service_name, expected_username, "test_password" + ) + + +def test_set_password_when_given_none_uses_password_from_default_profile( + keyring_password_setter, config_accessor +): + values = create_profile_values_dict(username="test.username") + setup_mock_accessor(config_accessor, "Default_Profile", values) + config_accessor.name = "Default_Profile" + password.set_password(None, "test_password") + expected_service_name = "code42cli::Default_Profile" + expected_username = "test.username" + keyring_password_setter.assert_called_once_with( + expected_service_name, expected_username, "test_password" ) diff --git a/tests/profile/test_profile.py b/tests/profile/test_profile.py index 563f9fe3d..75bd4cd73 100644 --- a/tests/profile/test_profile.py +++ b/tests/profile/test_profile.py @@ -5,11 +5,12 @@ from code42cli.profile import profile from code42cli.profile.config import ConfigAccessor from .conftest import PASSWORD_NAMESPACE, PROFILE_NAMESPACE +from ..conftest import MockSection, create_mock_profile @pytest.fixture def config_accessor(mocker): - mock = mocker.MagicMock(spec=ConfigAccessor) + mock = mocker.MagicMock(spec=ConfigAccessor, name="Config Accessor") factory = mocker.patch("{0}.profile.get_config_accessor".format(PROFILE_NAMESPACE)) factory.return_value = mock return mock @@ -17,12 +18,12 @@ def config_accessor(mocker): @pytest.fixture(autouse=True) def password_setter(mocker): - return mocker.patch("{0}.set_password_from_prompt".format(PASSWORD_NAMESPACE)) + return mocker.patch("{0}.set_password".format(PASSWORD_NAMESPACE)) @pytest.fixture(autouse=True) def password_getter(mocker): - return mocker.patch("{0}.get_password".format(PASSWORD_NAMESPACE)) + return mocker.patch("{0}.get_stored_password".format(PASSWORD_NAMESPACE)) @pytest.fixture(autouse=True) @@ -30,33 +31,23 @@ def input_function(mocker): return mocker.patch("{0}.profile.get_input".format(PROFILE_NAMESPACE)) -def _get_profile_parser(): +def _get_arg_parser(): subcommand_parser = ArgumentParser().add_subparsers() profile.init(subcommand_parser) return subcommand_parser.choices.get("profile") -def create_profile(): - class MockSection(object): - name = "TEST" - - def get(*args): - pass - - return profile.Code42Profile(MockSection()) - - class TestCode42Profile(object): def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter): password_getter.return_value = None mock_getpass = mocker.patch("{0}.get_password_from_prompt".format(PASSWORD_NAMESPACE)) mock_getpass.return_value = "Test Password" - actual = create_profile().get_password() + actual = create_mock_profile().get_password() assert actual == "Test Password" def test_get_password_return_password_from_password_get_password(self, password_getter): password_getter.return_value = "Test Password" - actual = create_profile().get_password() + actual = create_mock_profile().get_password() assert actual == "Test Password" @@ -66,14 +57,21 @@ def test_init_adds_profile_subcommand_to_choices(config_accessor): assert subcommand_parser.choices.get("profile") -def test_init_adds_parser_that_can_parse_show_command(config_accessor): +def test_init_adds_parser_that_can_parse_show_command_without_profile(config_accessor): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + profile_parser = subcommand_parser.choices.get("profile") + assert profile_parser.parse_args(["show"]) + + +def test_init_adds_parser_that_can_parse_show_command_with_profile(config_accessor): subcommand_parser = ArgumentParser().add_subparsers() profile.init(subcommand_parser) profile_parser = subcommand_parser.choices.get("profile") assert profile_parser.parse_args(["show", "--profile", "name"]) -def test_init_adds_parser_that_can_parse_set_command(config_accessor): +def test_init_adds_parser_that_can_parse_set_command_without_profile(config_accessor): subcommand_parser = ArgumentParser().add_subparsers() profile.init(subcommand_parser) profile_parser = subcommand_parser.choices.get("profile") @@ -82,6 +80,24 @@ def test_init_adds_parser_that_can_parse_set_command(config_accessor): ) +def test_init_adds_parser_that_can_parse_set_command_with_profile(config_accessor): + subcommand_parser = ArgumentParser().add_subparsers() + profile.init(subcommand_parser) + profile_parser = subcommand_parser.choices.get("profile") + profile_parser.parse_args( + [ + "set", + "--profile", + "ProfileName", + "-s", + "server-arg", + "-u", + "username-arg", + "--enable-ssl-errors", + ] + ) + + def test_init_add_parser_that_can_parse_list_command(): subcommand_parser = ArgumentParser().add_subparsers() profile.init(subcommand_parser) @@ -104,14 +120,14 @@ def test_get_profile_returns_object_from_config_profile(mocker, config_accessor) def test_set_profile_when_given_username_sets_username(config_accessor): - parser = _get_profile_parser() + parser = _get_arg_parser() namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) profile.set_profile(namespace) assert config_accessor.set_username.call_args[0][0] == "a.new.user@example.com" def test_set_profile_when_given_profile_name_sets_username_for_profile(config_accessor): - parser = _get_profile_parser() + parser = _get_arg_parser() namespace = parser.parse_args(["set", "--profile", "profileA", "-u", "a.new.user@example.com"]) profile.set_profile(namespace) assert config_accessor.set_username.call_args[0][0] == "a.new.user@example.com" @@ -119,29 +135,28 @@ def test_set_profile_when_given_profile_name_sets_username_for_profile(config_ac def test_set_profile_when_given_authority_sets_authority(config_accessor): - parser = _get_profile_parser() + parser = _get_arg_parser() namespace = parser.parse_args(["set", "-s", "example.com"]) profile.set_profile(namespace) assert config_accessor.set_authority_url.call_args[0][0] == "example.com" def test_set_profile_when_given_profile_name_sets_authority_for_profile(config_accessor): - parser = _get_profile_parser() + parser = _get_arg_parser() namespace = parser.parse_args(["set", "--profile", "profileA", "-s", "example.com"]) profile.set_profile(namespace) - assert config_accessor.set_authority_url.call_args[0][0] == "example.com" - assert config_accessor.set_authority_url.call_args[0][1] == "profileA" + assert config_accessor.set_authority_url.call_args[0] == ("example.com", "profileA") def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true(config_accessor): - parser = _get_profile_parser() + parser = _get_arg_parser() namespace = parser.parse_args(["set", "--enable-ssl-errors"]) profile.set_profile(namespace) assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == False def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_true(config_accessor): - parser = _get_profile_parser() + parser = _get_arg_parser() namespace = parser.parse_args(["set", "--disable-ssl-errors"]) profile.set_profile(namespace) assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == True @@ -150,19 +165,21 @@ def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_tru def test_set_profile_when_given_disable_ssl_errors_and_profile_name_sets_ignore_ssl_errors_to_true_for_profile( config_accessor ): - parser = _get_profile_parser() + parser = _get_arg_parser() namespace = parser.parse_args(["set", "--profile", "profileA", "--disable-ssl-errors"]) profile.set_profile(namespace) - assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == True - assert config_accessor.set_ignore_ssl_errors.call_args[0][1] == "profileA" + assert config_accessor.set_ignore_ssl_errors.call_args[0] == (True, "profileA") def test_set_profile_when_to_store_password_prompts_for_storing_password( mocker, config_accessor, input_function ): + mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") + mock_successful_connection.return_value = True input_function.return_value = "y" - mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") - parser = _get_profile_parser() + mocker.patch("code42cli.profile.password.get_password_from_prompt") + mock_set_password_function = mocker.patch("code42cli.profile.password.set_password") + parser = _get_arg_parser() namespace = parser.parse_args( ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] ) @@ -170,34 +187,96 @@ def test_set_profile_when_to_store_password_prompts_for_storing_password( assert mock_set_password_function.call_count -def test_set_profile_when_told_to_store_password_using_capital_y_prompts_for_storing_password( +def test_set_profile_when_told_not_to_store_password_does_not_prompt_for_storing_password( mocker, config_accessor, input_function ): - input_function.return_value = "Y" - mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") - parser = _get_profile_parser() + input_function.return_value = "n" + mocker.patch("code42cli.profile.password.get_password_from_prompt") + parser = _get_arg_parser() + mock_set_password_function = mocker.patch("code42cli.profile.password.set_password") namespace = parser.parse_args( ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] ) profile.set_profile(namespace) - assert mock_set_password_function.call_count + assert not mock_set_password_function.call_count -def test_set_profile_when_told_not_to_store_password_prompts_for_storing_password( +def test_set_profile_when_told_to_store_password_but_connection_fails_exits( mocker, config_accessor, input_function ): - input_function.return_value = "n" - mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") - parser = _get_profile_parser() + mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") + mock_successful_connection.return_value = False + input_function.return_value = "y" + mocker.patch("code42cli.profile.password.get_password_from_prompt") + parser = _get_arg_parser() namespace = parser.parse_args( ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] ) - profile.set_profile(namespace) - assert not mock_set_password_function.call_count + with pytest.raises(SystemExit): + profile.set_profile(namespace) -def test_prompt_for_password_reset_calls_password_set_password_from_prompt(mocker, namespace): - namespace.profile_name = "profile name" - mock_set_password_function = mocker.patch("code42cli.profile.password.set_password_from_prompt") +def test_prompt_for_password_reset_when_connection_fails_does_not_reset_password( + mocker, config_accessor, input_function +): + mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") + mock_successful_connection.return_value = False + input_function.return_value = "y" + mocker.patch("code42cli.profile.password.get_password_from_prompt") + parser = _get_arg_parser() + namespace = parser.parse_args(["reset-pw", "--profile", "Test"]) + with pytest.raises(SystemExit): + profile.prompt_for_password_reset(namespace) + + +def test_prompt_for_password_when_not_given_profile_name_calls_set_password_with_default_profile( + mocker, config_accessor, input_function +): + default_profile = MockSection() + config_accessor.get_profile.return_value = default_profile + mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") + mock_successful_connection.return_value = True + input_function.return_value = "y" + password_prompt = mocker.patch("code42cli.profile.password.get_password_from_prompt") + password_prompt.return_value = "new password" + parser = _get_arg_parser() + namespace = parser.parse_args(["reset-pw"]) + mock_set_password_function = mocker.patch("code42cli.profile.password.set_password") profile.prompt_for_password_reset(namespace) - assert mock_set_password_function.call_count + mock_set_password_function.assert_called_once_with(default_profile.name, "new password") + + +def test_list_profiles_when_no_profiles_prints_error(mocker, config_accessor): + config_accessor.get_all_profiles.return_value = [] + mock_error_printer = mocker.patch("code42cli.util.print_error") + parser = _get_arg_parser() + namespace = parser.parse_args(["list"]) + profile.list_profiles(namespace) + mock_error_printer.assert_called_once_with("No existing profile.") + + +def test_list_profiles_when_profiles_exists_does_not_print_error(mocker, config_accessor): + config_accessor.get_all_profiles.return_value = [MockSection()] + mock_error_printer = mocker.patch("code42cli.util.print_error") + parser = _get_arg_parser() + namespace = parser.parse_args(["list"]) + profile.list_profiles(namespace) + assert not mock_error_printer.call_count + + +def test_use_profile_when_switching_fails_causes_exit(config_accessor): + def side_effect(*args): + raise Exception() + + config_accessor.switch_default_profile.side_effect = side_effect + parser = _get_arg_parser() + namespace = parser.parse_args(["use", "TestProfile"]) + with pytest.raises(SystemExit): + profile.use_profile(namespace) + + +def test_use_profile_calls_accessor_with_expected_profile_name(config_accessor): + parser = _get_arg_parser() + namespace = parser.parse_args(["use", "TestProfile"]) + profile.use_profile(namespace) + config_accessor.switch_default_profile.assert_called_once_with("TestProfile") diff --git a/tests/securitydata/conftest.py b/tests/securitydata/conftest.py index 28b64ff0d..1e2f6256d 100644 --- a/tests/securitydata/conftest.py +++ b/tests/securitydata/conftest.py @@ -5,11 +5,14 @@ SECURITYDATA_NAMESPACE = "code42cli.securitydata" SUBCOMMANDS_NAMESPACE = "{0}.subcommands".format(SECURITYDATA_NAMESPACE) - -begin_date_tuple = (get_test_date_str(days_ago=89),) -begin_date_tuple_with_time = (get_test_date_str(days_ago=89), "3:12:33") -end_date_tuple = (get_test_date_str(days_ago=10),) -end_date_tuple_with_time = (get_test_date_str(days_ago=10), "11:22:43") +begin_date_str = get_test_date_str(days_ago=89) +begin_date_str_with_time = "{0} 3:12:33".format(begin_date_str) +end_date_str = get_test_date_str(days_ago=10) +end_date_str_with_time = "{0} 11:22:43".format(end_date_str) +begin_date_list = [get_test_date_str(days_ago=89)] +begin_date_list_with_time = [get_test_date_str(days_ago=89), "3:12:33"] +end_date_list = [get_test_date_str(days_ago=10)] +end_date_list_with_time = [get_test_date_str(days_ago=10), "11:22:43"] @pytest.fixture(autouse=True) diff --git a/tests/securitydata/subcommands/test_print_out.py b/tests/securitydata/subcommands/test_print_out.py index d99ce361b..21a81281b 100644 --- a/tests/securitydata/subcommands/test_print_out.py +++ b/tests/securitydata/subcommands/test_print_out.py @@ -3,21 +3,12 @@ import pytest import code42cli.securitydata.subcommands.print_out as printer -from code42cli.profile.config import ConfigAccessor from .conftest import ACCEPTABLE_ARGS from ..conftest import SUBCOMMANDS_NAMESPACE _PRINT_PATH = "{0}.print_out".format(SUBCOMMANDS_NAMESPACE) -@pytest.fixture -def config_accessor(mocker): - mock = mocker.MagicMock(spec=ConfigAccessor) - factory = mocker.patch("") - factory.return_value = mock - return mock - - @pytest.fixture def logger_factory(mocker): return mocker.patch("{0}.get_logger_for_stdout".format(_PRINT_PATH)) diff --git a/tests/securitydata/test_date_helper.py b/tests/securitydata/test_date_helper.py index f57e8b44c..1094c2779 100644 --- a/tests/securitydata/test_date_helper.py +++ b/tests/securitydata/test_date_helper.py @@ -2,48 +2,58 @@ from code42cli.securitydata.date_helper import create_event_timestamp_filter from .conftest import ( - begin_date_tuple, - begin_date_tuple_with_time, - end_date_tuple, - end_date_tuple_with_time, + begin_date_list, + begin_date_list_with_time, + end_date_list, + end_date_list_with_time, ) from ..conftest import get_filter_value_from_json, get_test_date_str +def test_create_event_timestamp_filter_when_given_nothing_returns_none(): + ts_range = create_event_timestamp_filter() + assert not ts_range + + +def test_create_event_timestamp_filter_when_given_nones_returns_none(): + ts_range = create_event_timestamp_filter(None, None) + assert not ts_range + + def test_create_event_timestamp_filter_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_tuple) + ts_range = create_event_timestamp_filter(begin_date_list) actual = get_filter_value_from_json(ts_range, filter_index=0) - expected = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) + expected = "{0}T00:00:00.000Z".format(begin_date_list[0]) assert actual == expected def test_create_event_timestamp_filter_when_given_begin_with_time_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_tuple_with_time) + ts_range = create_event_timestamp_filter(begin_date_list_with_time) actual = get_filter_value_from_json(ts_range, filter_index=0) - expected = "{0}T0{1}.000Z".format(begin_date_tuple_with_time[0], begin_date_tuple_with_time[1]) + expected = "{0}T0{1}.000Z".format(begin_date_list_with_time[0], begin_date_list_with_time[1]) assert actual == expected def test_create_event_timestamp_filter_when_given_end_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_tuple, end_date_tuple) + ts_range = create_event_timestamp_filter(begin_date_list, end_date_list) actual = get_filter_value_from_json(ts_range, filter_index=1) - expected = "{0}T23:59:59.000Z".format(end_date_tuple[0]) + expected = "{0}T23:59:59.000Z".format(end_date_list[0]) assert actual == expected def test_create_event_timestamp_filter_when_given_end_with_time_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_tuple, end_date_tuple_with_time) + ts_range = create_event_timestamp_filter(begin_date_list, end_date_list_with_time) actual = get_filter_value_from_json(ts_range, filter_index=1) - expected = "{0}T{1}.000Z".format(end_date_tuple_with_time[0], end_date_tuple_with_time[1]) + expected = "{0}T{1}.000Z".format(end_date_list_with_time[0], end_date_list_with_time[1]) assert actual == expected def test_create_event_timestamp_filter_when_given_both_begin_and_end_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_tuple, end_date_tuple_with_time) + ts_range = create_event_timestamp_filter(begin_date_list, end_date_list_with_time) actual_begin = get_filter_value_from_json(ts_range, filter_index=0) actual_end = get_filter_value_from_json(ts_range, filter_index=1) - expected_begin = "{0}T00:00:00.000Z".format(begin_date_tuple[0]) - expected_end = "{0}T{1}.000Z".format(end_date_tuple_with_time[0], end_date_tuple_with_time[1]) + expected_begin = "{0}T00:00:00.000Z".format(begin_date_list[0]) + expected_end = "{0}T{1}.000Z".format(end_date_list_with_time[0], end_date_list_with_time[1]) assert actual_begin == expected_begin assert actual_end == expected_end diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index 504b6db2b..b5b54e49f 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -7,7 +7,7 @@ import code42cli.securitydata.extraction as extraction_module from code42cli.securitydata.options import ExposureType as ExposureTypeOptions -from .conftest import SECURITYDATA_NAMESPACE, begin_date_tuple +from .conftest import SECURITYDATA_NAMESPACE, begin_date_str from ..conftest import get_filter_value_from_json, get_test_date_str @@ -45,7 +45,7 @@ def profile(mocker): @pytest.fixture def namespace_with_begin(namespace): - namespace.begin_date = begin_date_tuple + namespace.begin_date = begin_date_str return namespace @@ -196,46 +196,50 @@ def test_extract_when_not_given_begin_or_advanced_causes_exit(logger, extractor, def test_extract_when_given_begin_date_uses_expected_query(logger, namespace, extractor): - namespace.begin_date = (get_test_date_str(days_ago=89),) + namespace.begin_date = get_test_date_str(days_ago=89) extraction_module.extract(logger, namespace) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) + expected = "{0}T00:00:00.000Z".format(namespace.begin_date) assert actual == expected def test_extract_when_given_begin_date_and_time_uses_expected_query(logger, namespace, extractor): - namespace.begin_date = (get_test_date_str(days_ago=89), "15:33:02") + date = get_test_date_str(days_ago=89) + time = "15:33:02" + namespace.begin_date = get_test_date_str(days_ago=89) + " " + time extraction_module.extract(logger, namespace) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T{1}.000Z".format(namespace.begin_date[0], namespace.begin_date[1]) + expected = "{0}T{1}.000Z".format(date, time) assert actual == expected def test_extract_when_given_end_date_uses_expected_query(logger, namespace_with_begin, extractor): - namespace_with_begin.end_date = (get_test_date_str(days_ago=10),) + namespace_with_begin.end_date = get_test_date_str(days_ago=10) extraction_module.extract(logger, namespace_with_begin) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T23:59:59.000Z".format(namespace_with_begin.end_date[0]) + expected = "{0}T23:59:59.000Z".format(namespace_with_begin.end_date) assert actual == expected def test_extract_when_given_end_date_and_time_uses_expected_query( logger, namespace_with_begin, extractor ): - namespace_with_begin.end_date = (get_test_date_str(days_ago=10), "12:00:11") + date = get_test_date_str(days_ago=10) + time = "12:00:11" + namespace_with_begin.end_date = date + " " + time extraction_module.extract(logger, namespace_with_begin) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T{1}.000Z".format( - namespace_with_begin.end_date[0], namespace_with_begin.end_date[1] - ) + expected = "{0}T{1}.000Z".format(date, time) assert actual == expected def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( logger, namespace, extractor ): - namespace.begin_date = (get_test_date_str(days_ago=89),) - namespace.end_date = (get_test_date_str(days_ago=55), "13:44:44") + end_date = get_test_date_str(days_ago=55) + end_time = "13:44:44" + namespace.begin_date = get_test_date_str(days_ago=89) + namespace.end_date = end_date + " " + end_time extraction_module.extract(logger, namespace) actual_begin_timestamp = get_filter_value_from_json( @@ -244,8 +248,8 @@ def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( actual_end_timestamp = get_filter_value_from_json( extractor.extract.call_args[0][0], filter_index=1 ) - expected_begin_timestamp = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) - expected_end_timestamp = "{0}T{1}.000Z".format(namespace.end_date[0], namespace.end_date[1]) + expected_begin_timestamp = "{0}T00:00:00.000Z".format(namespace.begin_date) + expected_end_timestamp = "{0}T{1}.000Z".format(end_date, end_time) assert actual_begin_timestamp == expected_begin_timestamp assert actual_end_timestamp == expected_end_timestamp @@ -255,14 +259,15 @@ def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_m logger, namespace, extractor ): namespace.is_incremental = False - namespace.begin_date = (get_test_date_str(days_ago=91), "12:51:00") + date = get_test_date_str(days_ago=91) + " 12:51:00" + namespace.begin_date = date with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) def test_extract_when_end_date_is_before_begin_date_causes_exit(logger, namespace, extractor): - namespace.begin_date = (get_test_date_str(days_ago=5),) - namespace.end_date = (get_test_date_str(days_ago=6),) + namespace.begin_date = get_test_date_str(days_ago=5) + namespace.end_date = get_test_date_str(days_ago=6) with pytest.raises(SystemExit): extraction_module.extract(logger, namespace) @@ -283,7 +288,7 @@ def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_curs def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( mocker, logger, namespace, extractor ): - namespace.begin_date = (get_test_date_str(days_ago=1),) + namespace.begin_date = get_test_date_str(days_ago=1) namespace.is_incremental = False mock_checkpoint = mocker.patch( "code42cli.securitydata.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" @@ -292,7 +297,7 @@ def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_b extraction_module.extract(logger, namespace) actual_ts = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) - expected_ts = "{0}T00:00:00.000Z".format(namespace.begin_date[0]) + expected_ts = "{0}T00:00:00.000Z".format(namespace.begin_date) assert actual_ts == expected_ts assert filter_term_is_in_call_args(extractor, EventTimestamp._term) @@ -320,20 +325,6 @@ def test_extract_when_given_invalid_exposure_type_causes_exit(logger, namespace, extraction_module.extract(logger, namespace) -def test_extract_when_given_begin_date_with_len_3_causes_exit(logger, namespace, extractor): - namespace.begin_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_given_end_date_with_len_3_causes_exit( - logger, namespace_with_begin, extractor -): - namespace_with_begin.end_date = (get_test_date_str(days_ago=5), "12:00:00", "+600") - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace_with_begin) - - def test_extract_when_given_username_uses_username_filter(logger, namespace_with_begin, extractor): namespace_with_begin.c42usernames = ["test.testerson@example.com"] extraction_module.extract(logger, namespace_with_begin) diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py new file mode 100644 index 000000000..a92412960 --- /dev/null +++ b/tests/test_sdk_client.py @@ -0,0 +1,46 @@ +import pytest + +from py42 import debug_level +from py42 import settings + +from code42cli.sdk_client import create_sdk, validate_connection +from .conftest import create_mock_profile + + +@pytest.fixture +def mock_sdk_factory(mocker): + return mocker.patch("py42.sdk.SDK.create_using_local_account") + + +@pytest.fixture +def error_sdk_factory(mocker, mock_sdk_factory): + def side_effect(): + raise Exception() + + mock_sdk_factory.side_effect = side_effect + return mock_sdk_factory + + +def test_create_sdk_when_py42_exception_occurs_causes_exit(error_sdk_factory): + profile = create_mock_profile() + with pytest.raises(SystemExit): + create_sdk(profile, False) + + +def test_create_sdk_when_told_to_debug_turns_on_debug(mock_sdk_factory): + profile = create_mock_profile() + create_sdk(profile, True) + assert settings.debug_level == debug_level.DEBUG + + +def test_validate_connection_when_creating_sdk_raises_returns_false(error_sdk_factory): + assert not validate_connection("Test", "Password", "Authority") + + +def test_validate_connection_when_sdk_does_not_raise_returns_true(mock_sdk_factory): + assert validate_connection("Test", "Password", "Authority") + + +def test_validate_connection_uses_given_credentials(mock_sdk_factory): + assert validate_connection("Authority", "Test", "Password") + mock_sdk_factory.assert_called_once_with("Authority", "Test", "Password") From 2217fbb767ae283d148592644c3a7057a2fd46ea Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 13 Mar 2020 14:06:23 -0500 Subject: [PATCH 014/349] Bugfix/encoding py2 (#16) --- CHANGELOG.md | 8 +++++++- src/code42cli/__version__.py | 2 +- src/code42cli/compat.py | 3 +++ src/code42cli/util.py | 1 + 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a568f32..c0903172e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. + +## 0.4.2 - 2020-03-13 + +### Fixed + +- Bug where encoding would cause an error when opening files on python2. + ## 0.4.1 - 2020-03-13 ### Fixed @@ -16,7 +23,6 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Bug where `profile show` indicated a password was set for a different profile. - We now validate credentials when setting a password. - ### Changed - Date inputs are now required to be in quotes when they include a time. diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 3d26edf77..df1243329 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.4.1" +__version__ = "0.4.2" diff --git a/src/code42cli/compat.py b/src/code42cli/compat.py index 9a94559a9..29b9c6872 100644 --- a/src/code42cli/compat.py +++ b/src/code42cli/compat.py @@ -15,11 +15,14 @@ from urlparse import urljoin, urlparse str = unicode + import io + open = io.open import repr as reprlib else: from urllib.parse import urljoin, urlparse str = str + open = open import reprlib diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 81fb48136..0df4c3a8a 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -2,6 +2,7 @@ import sys from os import path, makedirs +from code42cli.compat import open def get_input(prompt): From c5e78777a31970be5258e719f314ae05df696554 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 16 Mar 2020 16:10:55 -0500 Subject: [PATCH 015/349] Feature/headless pw mgmt (#17) --- CHANGELOG.md | 9 ++ README.md | 6 ++ src/code42cli/__version__.py | 2 +- src/code42cli/compat.py | 2 + src/code42cli/profile/config.py | 2 +- src/code42cli/profile/password.py | 90 ++++++++++++++++-- src/code42cli/profile/profile.py | 8 +- src/code42cli/sdk_client.py | 1 - src/code42cli/securitydata/extraction.py | 2 +- src/code42cli/securitydata/logger_factory.py | 10 +- src/code42cli/util.py | 27 ++++-- tests/profile/test_config.py | 3 +- tests/profile/test_password.py | 97 +++++++++++++++++++- tests/profile/test_profile.py | 52 +++++++---- tests/securitydata/test_logger_factory.py | 18 ++-- tests/test_sdk_client.py | 1 - tests/test_util.py | 20 +++- 17 files changed, 286 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0903172e..7dc908030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 0.4.3 - 2020-03-16 + +### Added + +- Support for storing passwords when keying is not available. + +### Fixed + +- Bug where keyring caused errors on certain operating systems when not supported. ## 0.4.2 - 2020-03-13 diff --git a/README.md b/README.md index de4f76fec..3bd7055a2 100644 --- a/README.md +++ b/README.md @@ -130,3 +130,9 @@ To learn more about acceptable arguments, add the `-h` flag to `code42` or any o # Known Issues Only the first 10,000 of each set of events containing the exact same insertion timestamp is reported. + + +# Troubleshooting + +If you keep getting prompted for your password, try resetting with `code42 profile reset-pw`. +If that doesn't work, delete your credentials file located at ~/.code42cli or the entry in keychain. diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index df1243329..f6b7e267c 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.4.2" +__version__ = "0.4.3" diff --git a/src/code42cli/compat.py b/src/code42cli/compat.py index 29b9c6872..620df9e97 100644 --- a/src/code42cli/compat.py +++ b/src/code42cli/compat.py @@ -15,7 +15,9 @@ from urlparse import urljoin, urlparse str = unicode + import io + open = io.open import repr as reprlib diff --git a/src/code42cli/profile/config.py b/src/code42cli/profile/config.py index 660feca76..b2359595a 100644 --- a/src/code42cli/profile/config.py +++ b/src/code42cli/profile/config.py @@ -115,7 +115,7 @@ def _create_profile_section(self, name): self._internal[self.DEFAULT_PROFILE] = name def _save(self): - util.open_file(self.path, u"w+", lambda f: self.parser.write(f)) + util.open_file(self.path, u"w+", lambda file: self.parser.write(file)) def _try_complete_setup(self, profile): if self._internal.getboolean(self.DEFAULT_PROFILE_IS_COMPLETE): diff --git a/src/code42cli/profile/password.py b/src/code42cli/profile/password.py index ec2b65c3f..8e3c23d46 100644 --- a/src/code42cli/profile/password.py +++ b/src/code42cli/profile/password.py @@ -1,10 +1,13 @@ from __future__ import print_function +import os +import stat from getpass import getpass import keyring from code42cli.profile.config import get_config_accessor, ConfigAccessor +from code42cli.util import does_user_agree, open_file, get_user_project_path, print_error _ROOT_SERVICE_NAME = u"code42cli" @@ -12,10 +15,7 @@ def get_stored_password(profile_name): """Gets your currently stored password for the given profile name.""" profile = _get_profile(profile_name) - service_name = _get_service_name(profile.name) - username = _get_username(profile) - password = keyring.get_password(service_name, username) - return password + return _get_stored_password(profile) def get_password_from_prompt(): @@ -26,10 +26,10 @@ def get_password_from_prompt(): def set_password(profile_name, new_password): """Sets your password for the given profile name.""" profile = _get_profile(profile_name) - service_name = _get_service_name(profile.name) + service_name = _get_keyring_service_name(profile.name) username = _get_username(profile) - keyring.set_password(service_name, username, new_password) - print(u"'Code42 Password' updated.") + if _store_password(profile, service_name, username, new_password): + print(u"'Code42 Password' updated.") def _get_profile(profile_name): @@ -37,9 +37,83 @@ def _get_profile(profile_name): return accessor.get_profile(profile_name) -def _get_service_name(profile_name): +def _get_stored_password(profile): + password = _get_password_from_keyring(profile) or _get_password_from_file(profile) + return password + + +def _get_keyring_service_name(profile_name): return u"{}::{}".format(_ROOT_SERVICE_NAME, profile_name) +def _get_password_from_keyring(profile): + try: + service_name = _get_keyring_service_name(profile.name) + username = _get_username(profile) + return keyring.get_password(service_name, username) + except: + return None + + +def _get_password_from_file(profile): + path = _get_password_file_path(profile) + + def read_password(file): + try: + return file.readline().strip() + except Exception: + return None + + try: + return open_file(path, u"r", lambda file: read_password(file)) + except Exception: + return None + + +def _store_password(profile, service_name, username, new_password): + return _store_password_using_keyring( + service_name, username, new_password + ) or _store_password_using_file(profile, new_password) + + +def _store_password_using_keyring(service_name, username, new_password): + try: + keyring.set_password(service_name, username, new_password) + was_successful = keyring.get_password(service_name, username) is not None + return was_successful + except: + return False + + +def _store_password_using_file(profile, new_password): + save_to_file = _prompt_for_alternative_store() + if save_to_file: + path = _get_password_file_path(profile) + + def write_password(file): + try: + file.truncate(0) + line = u"{0}\n".format(new_password) + file.write(line) + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + return True + except Exception as ex: + print_error(str(ex)) + return False + + return open_file(path, u"w+", lambda file: write_password(file)) + return False + + +def _get_password_file_path(profile): + project_path = get_user_project_path() + return u"{0}.{1}".format(project_path, profile.name.lower()) + + def _get_username(profile): return profile[ConfigAccessor.USERNAME_KEY] + + +def _prompt_for_alternative_store(): + prompt = u"keyring is unavailable. Would you like to store in secure flat file? (y/n): " + return does_user_agree(prompt) diff --git a/src/code42cli/profile/profile.py b/src/code42cli/profile/profile.py index 0639c66e7..8ae184353 100644 --- a/src/code42cli/profile/profile.py +++ b/src/code42cli/profile/profile.py @@ -4,13 +4,13 @@ import code42cli.profile.password as password from code42cli.compat import str from code42cli.profile.config import get_config_accessor, ConfigAccessor +from code42cli.sdk_client import validate_connection from code42cli.util import ( - get_input, + does_user_agree, print_error, print_set_profile_help, print_no_existing_profile_message, ) -from code42cli.sdk_client import validate_connection class Code42Profile(object): @@ -112,6 +112,7 @@ def prompt_for_password_reset(args): """Securely prompts for your password and then stores it using keyring.""" profile = get_profile(args.profile_name) new_password = password.get_password_from_prompt() + if not validate_connection(profile.authority_url, profile.username, new_password): print_error( "Your password was not saved because your credentials failed to validate. " @@ -254,8 +255,7 @@ def _default_profile_exist(): def _prompt_for_allow_password_set(args): - answer = get_input(u"Would you like to set a password? (y/n): ") - if answer.lower() == u"y": + if does_user_agree(u"Would you like to set a password? (y/n): "): prompt_for_password_reset(args) diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index 875edfc66..a622aa1af 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -24,5 +24,4 @@ def validate_connection(authority_url, username, password): SDK.create_using_local_account(authority_url, username, password) return True except: - print(username, password, authority_url) return False diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index 203269801..73658cf1c 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -12,6 +12,7 @@ from code42cli.compat import str from code42cli.profile.profile import get_profile +from code42cli.sdk_client import create_sdk from code42cli.securitydata import date_helper as date_helper from code42cli.securitydata.arguments.main import IS_INCREMENTAL_KEY from code42cli.securitydata.arguments.search import SearchArguments @@ -19,7 +20,6 @@ from code42cli.securitydata.logger_factory import get_error_logger from code42cli.securitydata.options import ExposureType as ExposureTypeOptions from code42cli.util import print_error, print_bold, is_interactive -from code42cli.sdk_client import create_sdk _EXCEPTIONS_OCCURRED = False diff --git a/src/code42cli/securitydata/logger_factory.py b/src/code42cli/securitydata/logger_factory.py index e1c91fea1..c114aff7c 100644 --- a/src/code42cli/securitydata/logger_factory.py +++ b/src/code42cli/securitydata/logger_factory.py @@ -68,9 +68,13 @@ def get_logger_for_server(hostname, protocol, output_format): if not _logger_has_handlers(logger): url_parts = get_url_parts(hostname) port = url_parts[1] or 514 - handler = NoPrioritySysLogHandlerWrapper( - url_parts[0], port=port, protocol=protocol - ).handler + try: + handler = NoPrioritySysLogHandlerWrapper( + url_parts[0], port=port, protocol=protocol + ).handler + except: + print_error(u"Unable to connect to {0}.".format(hostname)) + exit(1) return _init_logger(logger, handler, output_format) return logger diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 0df4c3a8a..2c0420508 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -2,6 +2,7 @@ import sys from os import path, makedirs + from code42cli.compat import open @@ -14,31 +15,37 @@ def get_input(prompt): return raw_input(prompt) -def get_user_project_path(subdir=""): - """The path on your user dir to /.code42cli/[subdir].""" - package_name = __name__.split(".")[0] - home = path.expanduser("~") - user_project_path = path.join(home, ".{0}".format(package_name), subdir) +def does_user_agree(prompt): + """Prompts the user and checks if they said yes.""" + ans = get_input(prompt) + ans = ans.strip().lower() + return ans == u"y" + +def get_user_project_path(subdir=u""): + """The path on your user dir to /.code42cli/[subdir].""" + package_name = __name__.split(u".")[0] + home = path.expanduser(u"~") + hidden_package_name = u".{0}".format(package_name) + user_project_path = path.join(home, hidden_package_name, subdir) if not path.exists(user_project_path): makedirs(user_project_path) - return user_project_path def open_file(file_path, mode, action): """Wrapper for opening files, useful for testing purposes.""" - with open(file_path, mode, encoding="utf-8") as f: - action(f) + with open(file_path, mode, encoding=u"utf-8") as f: + return action(f) def print_error(error_text): """Prints red text.""" - print("\033[91mERROR: {}\033[0m".format(error_text)) + print(u"\033[91mERROR: {}\033[0m".format(error_text)) def print_bold(bold_text): - print("\033[1m{}\033[0m".format(bold_text)) + print(u"\033[1m{}\033[0m".format(bold_text)) def is_interactive(): diff --git a/tests/profile/test_config.py b/tests/profile/test_config.py index b9cfe688d..2a4b2105b 100644 --- a/tests/profile/test_config.py +++ b/tests/profile/test_config.py @@ -1,8 +1,9 @@ from __future__ import with_statement -import pytest from configparser import ConfigParser +import pytest + from code42cli.profile.config import ConfigAccessor from ..conftest import MockSection diff --git a/tests/profile/test_password.py b/tests/profile/test_password.py index a44de41a9..39aea0a06 100644 --- a/tests/profile/test_password.py +++ b/tests/profile/test_password.py @@ -31,11 +31,30 @@ def getpass_function(mocker): return mocker.patch("code42cli.profile.password.getpass") +@pytest.fixture +def user_agreement(mocker): + mock = mocker.patch("code42cli.profile.password.does_user_agree") + mock.return_value = True + return mock + + +@pytest.fixture +def user_disagreement(mocker): + mock = mocker.patch("code42cli.profile.password.does_user_agree") + mock.return_value = False + return mock + + +@pytest.fixture +def file_opener(mocker): + return mocker.patch("code42cli.profile.password.open_file") + + def test_get_stored_password_when_given_profile_name_gets_profile_for_that_name( keyring_password_getter, config_accessor ): password.get_stored_password("profile_name") - config_accessor.get_profile.assert_called_once_with("profile_name") + assert config_accessor.get_profile.call_args_list[0][0][0] == "profile_name" def test_get_stored_password_returns_expected_password( @@ -45,9 +64,49 @@ def test_get_stored_password_returns_expected_password( assert password.get_stored_password("profile_name") == "already stored password 123" +def test_get_stored_password_when_keyring_returns_none_returns_password_from_file( + keyring_password_getter, config_accessor, file_opener +): + keyring_password_getter.return_value = None + file_opener.return_value = "FileStoredPassword123!" + assert password.get_stored_password("profile_name") == "FileStoredPassword123!" + + +def test_get_stored_password_when_keyring_throws_exception_returns_password_from_file( + keyring_password_getter, config_accessor, file_opener +): + def side_effect(*args): + raise Exception() + + keyring_password_getter.side_effect = side_effect + file_opener.return_value = "FileStoredPassword123!" + assert password.get_stored_password("profile_name") == "FileStoredPassword123!" + + +def test_get_stored_password_when_not_in_keyring_or_file_returns_none( + keyring_password_getter, config_accessor, file_opener +): + keyring_password_getter.return_value = None + file_opener.return_value = None + assert not password.get_stored_password("profile_name") + + +def test_get_stored_password_when_not_in_keyring_and_getting_from_file_throws_returns_none( + keyring_password_getter, config_accessor, file_opener +): + keyring_password_getter.return_value = None + + def side_effect(*args): + raise Exception() + + file_opener.side_effect = side_effect + assert not password.get_stored_password("profile_name") + + def test_set_password_uses_expected_service_name_username_and_password( - keyring_password_setter, config_accessor + keyring_password_setter, config_accessor, keyring_password_getter ): + keyring_password_getter.return_value = "test_password" values = create_profile_values_dict(username="test.username") setup_mock_accessor(config_accessor, "profile_name", values) password.set_password("profile_name", "test_password") @@ -59,8 +118,9 @@ def test_set_password_uses_expected_service_name_username_and_password( def test_set_password_when_given_none_uses_password_from_default_profile( - keyring_password_setter, config_accessor + keyring_password_setter, config_accessor, keyring_password_getter ): + keyring_password_getter.return_value = "test_password" values = create_profile_values_dict(username="test.username") setup_mock_accessor(config_accessor, "Default_Profile", values) config_accessor.name = "Default_Profile" @@ -70,3 +130,34 @@ def test_set_password_when_given_none_uses_password_from_default_profile( keyring_password_setter.assert_called_once_with( expected_service_name, expected_username, "test_password" ) + + +def test_set_password_when_not_found_in_keyring_after_set_and_user_agrees_stores_in_file( + keyring_password_setter, config_accessor, keyring_password_getter, user_agreement, file_opener +): + keyring_password_getter.return_value = None + password.set_password("profile_name", "test_password") + assert file_opener.call_count == 1 + + +def test_set_password_when_not_found_in_keyring_after_set_and_user_disagrees_does_not_store( + keyring_password_setter, + config_accessor, + keyring_password_getter, + user_disagreement, + file_opener, +): + keyring_password_getter.return_value = None + password.set_password("profile_name", "test_password") + assert file_opener.call_count == 0 + + +def test_set_password_when_keyring_throws_and_user_agrees_stores_in_file( + keyring_password_setter, config_accessor, user_agreement, file_opener +): + def side_effect(*args): + raise Exception() + + keyring_password_setter.side_effect = side_effect + password.set_password("profile_name", "test_password") + assert file_opener.call_count == 1 diff --git a/tests/profile/test_profile.py b/tests/profile/test_profile.py index 75bd4cd73..c68567d2c 100644 --- a/tests/profile/test_profile.py +++ b/tests/profile/test_profile.py @@ -26,9 +26,18 @@ def password_getter(mocker): return mocker.patch("{0}.get_stored_password".format(PASSWORD_NAMESPACE)) -@pytest.fixture(autouse=True) -def input_function(mocker): - return mocker.patch("{0}.profile.get_input".format(PROFILE_NAMESPACE)) +@pytest.fixture +def user_agreement(mocker): + mock = mocker.patch("{0}.profile.does_user_agree".format(PROFILE_NAMESPACE)) + mock.return_value = True + return mocker + + +@pytest.fixture +def user_disagreement(mocker): + mock = mocker.patch("{0}.profile.does_user_agree".format(PROFILE_NAMESPACE)) + mock.return_value = False + return mocker def _get_arg_parser(): @@ -119,14 +128,16 @@ def test_get_profile_returns_object_from_config_profile(mocker, config_accessor) assert user._profile == expected -def test_set_profile_when_given_username_sets_username(config_accessor): +def test_set_profile_when_given_username_sets_username(config_accessor, user_disagreement): parser = _get_arg_parser() namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) profile.set_profile(namespace) assert config_accessor.set_username.call_args[0][0] == "a.new.user@example.com" -def test_set_profile_when_given_profile_name_sets_username_for_profile(config_accessor): +def test_set_profile_when_given_profile_name_sets_username_for_profile( + config_accessor, user_disagreement +): parser = _get_arg_parser() namespace = parser.parse_args(["set", "--profile", "profileA", "-u", "a.new.user@example.com"]) profile.set_profile(namespace) @@ -134,28 +145,34 @@ def test_set_profile_when_given_profile_name_sets_username_for_profile(config_ac assert config_accessor.set_username.call_args[0][1] == "profileA" -def test_set_profile_when_given_authority_sets_authority(config_accessor): +def test_set_profile_when_given_authority_sets_authority(config_accessor, user_disagreement): parser = _get_arg_parser() namespace = parser.parse_args(["set", "-s", "example.com"]) profile.set_profile(namespace) assert config_accessor.set_authority_url.call_args[0][0] == "example.com" -def test_set_profile_when_given_profile_name_sets_authority_for_profile(config_accessor): +def test_set_profile_when_given_profile_name_sets_authority_for_profile( + config_accessor, user_disagreement +): parser = _get_arg_parser() namespace = parser.parse_args(["set", "--profile", "profileA", "-s", "example.com"]) profile.set_profile(namespace) assert config_accessor.set_authority_url.call_args[0] == ("example.com", "profileA") -def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true(config_accessor): +def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true( + config_accessor, user_disagreement +): parser = _get_arg_parser() namespace = parser.parse_args(["set", "--enable-ssl-errors"]) profile.set_profile(namespace) assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == False -def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_true(config_accessor): +def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_true( + config_accessor, user_disagreement +): parser = _get_arg_parser() namespace = parser.parse_args(["set", "--disable-ssl-errors"]) profile.set_profile(namespace) @@ -163,7 +180,7 @@ def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_tru def test_set_profile_when_given_disable_ssl_errors_and_profile_name_sets_ignore_ssl_errors_to_true_for_profile( - config_accessor + config_accessor, user_disagreement ): parser = _get_arg_parser() namespace = parser.parse_args(["set", "--profile", "profileA", "--disable-ssl-errors"]) @@ -172,11 +189,10 @@ def test_set_profile_when_given_disable_ssl_errors_and_profile_name_sets_ignore_ def test_set_profile_when_to_store_password_prompts_for_storing_password( - mocker, config_accessor, input_function + mocker, config_accessor, user_agreement ): mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") mock_successful_connection.return_value = True - input_function.return_value = "y" mocker.patch("code42cli.profile.password.get_password_from_prompt") mock_set_password_function = mocker.patch("code42cli.profile.password.set_password") parser = _get_arg_parser() @@ -188,9 +204,8 @@ def test_set_profile_when_to_store_password_prompts_for_storing_password( def test_set_profile_when_told_not_to_store_password_does_not_prompt_for_storing_password( - mocker, config_accessor, input_function + mocker, config_accessor, user_disagreement ): - input_function.return_value = "n" mocker.patch("code42cli.profile.password.get_password_from_prompt") parser = _get_arg_parser() mock_set_password_function = mocker.patch("code42cli.profile.password.set_password") @@ -202,11 +217,10 @@ def test_set_profile_when_told_not_to_store_password_does_not_prompt_for_storing def test_set_profile_when_told_to_store_password_but_connection_fails_exits( - mocker, config_accessor, input_function + mocker, config_accessor, user_agreement ): mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") mock_successful_connection.return_value = False - input_function.return_value = "y" mocker.patch("code42cli.profile.password.get_password_from_prompt") parser = _get_arg_parser() namespace = parser.parse_args( @@ -217,11 +231,10 @@ def test_set_profile_when_told_to_store_password_but_connection_fails_exits( def test_prompt_for_password_reset_when_connection_fails_does_not_reset_password( - mocker, config_accessor, input_function + mocker, config_accessor, user_agreement ): mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") mock_successful_connection.return_value = False - input_function.return_value = "y" mocker.patch("code42cli.profile.password.get_password_from_prompt") parser = _get_arg_parser() namespace = parser.parse_args(["reset-pw", "--profile", "Test"]) @@ -230,13 +243,12 @@ def test_prompt_for_password_reset_when_connection_fails_does_not_reset_password def test_prompt_for_password_when_not_given_profile_name_calls_set_password_with_default_profile( - mocker, config_accessor, input_function + mocker, config_accessor, user_agreement ): default_profile = MockSection() config_accessor.get_profile.return_value = default_profile mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") mock_successful_connection.return_value = True - input_function.return_value = "y" password_prompt = mocker.patch("code42cli.profile.password.get_password_from_prompt") password_prompt.return_value = "new password" parser = _get_arg_parser() diff --git a/tests/securitydata/test_logger_factory.py b/tests/securitydata/test_logger_factory.py index 567ca059f..97ec839c4 100644 --- a/tests/securitydata/test_logger_factory.py +++ b/tests/securitydata/test_logger_factory.py @@ -41,7 +41,7 @@ def test_get_logger_for_stdout_when_given_raw_json_format_uses_raw_json_formatte def test_get_logger_for_stdout_when_called_twice_has_only_one_handler(): - _ = factory.get_logger_for_stdout("CEF") + factory.get_logger_for_stdout("CEF") logger = factory.get_logger_for_stdout("CEF") assert len(logger.handlers) == 1 @@ -72,7 +72,7 @@ def test_get_logger_for_file_when_given_raw_json_format_uses_raw_json_formatter( def test_get_logger_for_file_when_called_twice_has_only_one_handler(): - _ = factory.get_logger_for_file("Test.out", "JSON") + factory.get_logger_for_file("Test.out", "JSON") logger = factory.get_logger_for_file("Test.out", "JSON") assert type(logger.handlers[0].formatter) == FileEventDictToJSONFormatter @@ -93,7 +93,7 @@ def test_get_logger_for_server_has_info_level(no_priority_syslog_handler): def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(no_priority_syslog_handler): - _ = factory.get_logger_for_server("example.com", "TCP", "CEF") + factory.get_logger_for_server("example.com", "TCP", "CEF") assert ( type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == FileEventDictToCEFFormatter ) @@ -103,7 +103,7 @@ def test_get_logger_for_server_when_given_json_format_uses_json_formatter( no_priority_syslog_handler ): factory.get_logger_for_server("example.com", "TCP", "JSON").handlers = [] - _ = factory.get_logger_for_server("example.com", "TCP", "JSON") + factory.get_logger_for_server("example.com", "TCP", "JSON") actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) assert actual == FileEventDictToJSONFormatter @@ -112,13 +112,13 @@ def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatte no_priority_syslog_handler ): factory.get_logger_for_server("example.com", "TCP", "RAW-JSON").handlers = [] - _ = factory.get_logger_for_server("example.com", "TCP", "RAW-JSON") + factory.get_logger_for_server("example.com", "TCP", "RAW-JSON") actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) assert actual == FileEventDictToRawJSONFormatter def test_get_logger_for_server_when_called_twice_only_has_one_handler(no_priority_syslog_handler): - _ = factory.get_logger_for_server("example.com", "TCP", "JSON") + factory.get_logger_for_server("example.com", "TCP", "JSON") logger = factory.get_logger_for_server("example.com", "TCP", "CEF") assert len(logger.handlers) == 1 @@ -129,13 +129,13 @@ def test_get_logger_for_server_uses_no_priority_syslog_handler(no_priority_syslo def test_get_logger_for_server_constructs_handler_with_expected_args( - mocker, no_priority_syslog_handler + mocker, no_priority_syslog_handler, monkeypatch ): no_priority_syslog_handler_wrapper = mocker.patch( "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" ) no_priority_syslog_handler_wrapper.return_value = None - _ = factory.get_logger_for_server("example.com", "TCP", "CEF") + factory.get_logger_for_server("example.com", "TCP", "CEF") no_priority_syslog_handler_wrapper.assert_called_once_with( "example.com", port=514, protocol="TCP" ) @@ -148,7 +148,7 @@ def test_get_logger_for_server_when_hostname_includes_port_constructs_handler_wi "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" ) no_priority_syslog_handler_wrapper.return_value = None - _ = factory.get_logger_for_server("example.com:999", "TCP", "CEF") + factory.get_logger_for_server("example.com:999", "TCP", "CEF") no_priority_syslog_handler_wrapper.assert_called_once_with( "example.com", port=999, protocol="TCP" ) diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index a92412960..224d2da85 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,5 +1,4 @@ import pytest - from py42 import debug_level from py42 import settings diff --git a/tests/test_util.py b/tests/test_util.py index 905be313a..15576d62d 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,4 @@ -from code42cli.util import get_url_parts +from code42cli.util import get_url_parts, does_user_agree def test_get_url_parts_when_given_host_and_port_returns_expected_parts(): @@ -11,3 +11,21 @@ def test_get_url_parts_when_given_host_without_port_returns_expected_parts(): url_str = "www.example.com" parts = get_url_parts(url_str) assert parts == ("www.example.com", None) + + +def test_does_user_agree_when_user_says_y_returns_true(mocker): + mock_input = mocker.patch("code42cli.util.get_input") + mock_input.return_value = "y" + assert does_user_agree("Test Prompt") + + +def test_does_user_agree_when_user_says_capital_y_returns_true(mocker): + mock_input = mocker.patch("code42cli.util.get_input") + mock_input.return_value = "Y" + assert does_user_agree("Test Prompt") + + +def test_does_user_agree_when_user_says_n_returns_false(mocker): + mock_input = mocker.patch("code42cli.util.get_input") + mock_input.return_value = "n" + assert not does_user_agree("Test Prompt") From 231cf93af796a2e7cbfc3792f3f3b02db0b132d1 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 17 Mar 2020 07:34:25 -0500 Subject: [PATCH 016/349] Improvement/help (#18) --- CHANGELOG.md | 4 ++ setup.py | 2 +- src/code42cli/main.py | 19 +++++-- src/code42cli/profile/profile.py | 52 ++++++++++++++++--- src/code42cli/sdk_client.py | 12 ++--- .../securitydata/arguments/search.py | 2 +- src/code42cli/securitydata/date_helper.py | 2 +- src/code42cli/securitydata/extraction.py | 6 +-- src/code42cli/securitydata/main.py | 27 +++++++--- .../subcommands/clear_checkpoint.py | 6 ++- .../securitydata/subcommands/print_out.py | 6 ++- .../securitydata/subcommands/send_to.py | 20 ++++--- .../securitydata/subcommands/write_to.py | 6 ++- tests/securitydata/test_extraction.py | 8 +-- tests/test_sdk_client.py | 10 ++-- 15 files changed, 128 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc908030..89d6af99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Bug where keyring caused errors on certain operating systems when not supported. +### Changed + +- Updated help texts to be more descriptive. + ## 0.4.2 - 2020-03-13 ### Fixed diff --git a/setup.py b/setup.py index 61b7627b3..288e344bd 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ packages=find_packages("src"), package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", - install_requires=["c42eventextractor==0.2.1", "keyring==18.0.1","py42==0.5.1"], + install_requires=["c42eventextractor==0.2.2", "keyring==18.0.1","py42==0.6.0"], license="MIT", include_package_data=True, zip_safe=False, diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 7926e0479..f5725a370 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,5 +1,5 @@ import platform -from argparse import ArgumentParser +from argparse import ArgumentParser, RawDescriptionHelpFormatter import code42cli.securitydata.main as securitydata from code42cli.compat import str @@ -7,7 +7,7 @@ # If on Windows, configure console session to handle ANSI escape sequences correctly # source: https://bugs.python.org/issue29059 -if platform.system().lower() == "windows": +if platform.system().lower() == u"windows": from ctypes import windll, c_int, byref stdout_handle = windll.kernel32.GetStdHandle(c_int(-11)) @@ -18,8 +18,17 @@ def main(): - code42_arg_parser = ArgumentParser() - subcommand_parser = code42_arg_parser.add_subparsers() + description = u""" + Groups: + profile - For managing Code42 settings. + securitydata - Tools for getting security related data, such as file events. + """ + code42_arg_parser = ArgumentParser( + formatter_class=RawDescriptionHelpFormatter, + description=description, + usage=u"code42 ", + ) + subcommand_parser = code42_arg_parser.add_subparsers(title=u"groups") profile.init(subcommand_parser) securitydata.init_subcommand(subcommand_parser) _run(code42_arg_parser) @@ -30,7 +39,7 @@ def _run(parser): args = parser.parse_args() args.func(args) except AttributeError as ex: - if str(ex) == "'Namespace' object has no attribute 'func'": + if str(ex) == u"'Namespace' object has no attribute 'func'": parser.print_help() return raise ex diff --git a/src/code42cli/profile/profile.py b/src/code42cli/profile/profile.py index 8ae184353..a3e4caadc 100644 --- a/src/code42cli/profile/profile.py +++ b/src/code42cli/profile/profile.py @@ -1,5 +1,7 @@ from __future__ import print_function +from argparse import RawDescriptionHelpFormatter + import code42cli.arguments as main_args import code42cli.profile.password as password from code42cli.compat import str @@ -52,14 +54,48 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser_profile = subcommand_parser.add_parser(u"profile") - profile_subparsers = parser_profile.add_subparsers() - - parser_for_show = profile_subparsers.add_parser(u"show") - parser_for_set = profile_subparsers.add_parser(u"set") - parser_for_reset_password = profile_subparsers.add_parser(u"reset-pw") - parser_for_list = profile_subparsers.add_parser(u"list") - parser_for_use = profile_subparsers.add_parser(u"use") + + description = u""" + Subcommands: + show - Print the details of a profile. + set - Create or update profile settings. The first profile created will be the default. + reset-pw - Change the stored password for a profile. + list - Show all existing stored profiles. + use - Set a profile as the default. + """ + parser_profile = subcommand_parser.add_parser( + u"profile", + formatter_class=RawDescriptionHelpFormatter, + description=description, + usage=u"code42 profile ", + ) + profile_subparsers = parser_profile.add_subparsers(title="subcommands") + + parser_for_show = profile_subparsers.add_parser( + u"show", + description=u"Print the details of a profile.", + usage=u"code42 profile show ", + ) + parser_for_set = profile_subparsers.add_parser( + u"set", + description=u"Create or update profile settings. The first profile created will be the default.", + usage=u"code42 profile set ", + ) + parser_for_reset_password = profile_subparsers.add_parser( + u"reset-pw", + description=u"Change the stored password for a profile.", + usage=u"code42 profile reset-pw ", + ) + parser_for_list = profile_subparsers.add_parser( + u"list", + description=u"Show all existing stored profiles.", + usage=u"code42 profile list ", + ) + parser_for_use = profile_subparsers.add_parser( + u"use", + description=u"Set a profile as the default.", + usage=u"code42 profile use ", + ) parser_for_show.set_defaults(func=show_profile) parser_for_set.set_defaults(func=set_profile) diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index a622aa1af..61ca9fcbe 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -1,16 +1,15 @@ -from py42 import debug_level -from py42 import settings -from py42.sdk import SDK +import py42.sdk +import py42.sdk.settings.debug as debug from code42cli.util import print_error def create_sdk(profile, is_debug_mode): if is_debug_mode: - settings.debug_level = debug_level.DEBUG + py42.sdk.settings.debug.level = debug.DEBUG try: password = profile.get_password() - return SDK.create_using_local_account(profile.authority_url, profile.username, password) + return py42.sdk.from_local_account(profile.authority_url, profile.username, password) except Exception: print_error( u"Invalid credentials or host address. " @@ -21,7 +20,8 @@ def create_sdk(profile, is_debug_mode): def validate_connection(authority_url, username, password): try: - SDK.create_using_local_account(authority_url, username, password) + py42.sdk.from_local_account(authority_url, username, password) return True except: + print(username, password, authority_url) return False diff --git a/src/code42cli/securitydata/arguments/search.py b/src/code42cli/securitydata/arguments/search.py index 11523566e..c4f9b5cb6 100644 --- a/src/code42cli/securitydata/arguments/search.py +++ b/src/code42cli/securitydata/arguments/search.py @@ -116,7 +116,7 @@ def _add_actor_arg(parser): nargs=u"+", action=u"store", dest=SearchArguments.ACTOR, - help=u"Limits events to only those enacted by these actors.", + help=u"Limits events to only those enacted by the cloud service user of the person who caused the event.", ) diff --git a/src/code42cli/securitydata/date_helper.py b/src/code42cli/securitydata/date_helper.py index 6a0581a36..d12875c34 100644 --- a/src/code42cli/securitydata/date_helper.py +++ b/src/code42cli/securitydata/date_helper.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from c42eventextractor.common import convert_datetime_to_timestamp -from py42.sdk.file_event_query.event_query import EventTimestamp +from py42.sdk.queries.fileevents.filters.event_filter import EventTimestamp _MAX_LOOK_BACK_DAYS = 90 _FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format." diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index 73658cf1c..7b790ba0b 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -4,11 +4,7 @@ from c42eventextractor import FileEventHandlers from c42eventextractor.extractors import FileEventExtractor -from py42.sdk.file_event_query.cloud_query import Actor -from py42.sdk.file_event_query.device_query import DeviceUsername -from py42.sdk.file_event_query.event_query import Source -from py42.sdk.file_event_query.exposure_query import ExposureType, ProcessOwner, TabURL -from py42.sdk.file_event_query.file_query import MD5, SHA256, FileName, FilePath +from py42.sdk.queries.fileevents.filters import * from code42cli.compat import str from code42cli.profile.profile import get_profile diff --git a/src/code42cli/securitydata/main.py b/src/code42cli/securitydata/main.py index e45e3de2a..166f47cd8 100644 --- a/src/code42cli/securitydata/main.py +++ b/src/code42cli/securitydata/main.py @@ -1,11 +1,26 @@ +from argparse import RawDescriptionHelpFormatter + from code42cli.securitydata.subcommands import clear_checkpoint, print_out, write_to from code42cli.securitydata.subcommands import send_to def init_subcommand(subcommand_parser): - securitydata_arg_parser = subcommand_parser.add_parser("securitydata") - securitydata_subparser = securitydata_arg_parser.add_subparsers() - send_to.init(securitydata_subparser) - write_to.init(securitydata_subparser) - print_out.init(securitydata_subparser) - clear_checkpoint.init(securitydata_subparser) + description = u""" + Subcommands: + print - Print file events to stdout. + send-to - Send file events to the given server address. + write-to - Write file events to the file with the given name. + clear-checkpoint - Remove the saved checkpoint from 'incremental' (-i) mode. + """ + securitydata_arg_parser = subcommand_parser.add_parser( + u"securitydata", + formatter_class=RawDescriptionHelpFormatter, + description=description, + epilog=u"Use '--profile ' to execute any of these commands for the given profile.", + usage=u"code42 securitydata ", + ) + securitydata_subparsers = securitydata_arg_parser.add_subparsers(title=u"subcommands") + send_to.init(securitydata_subparsers) + write_to.init(securitydata_subparsers) + print_out.init(securitydata_subparsers) + clear_checkpoint.init(securitydata_subparsers) diff --git a/src/code42cli/securitydata/subcommands/clear_checkpoint.py b/src/code42cli/securitydata/subcommands/clear_checkpoint.py index b59d04eed..c1768b7ed 100644 --- a/src/code42cli/securitydata/subcommands/clear_checkpoint.py +++ b/src/code42cli/securitydata/subcommands/clear_checkpoint.py @@ -8,7 +8,11 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser = subcommand_parser.add_parser("clear-checkpoint") + parser = subcommand_parser.add_parser( + u"clear-checkpoint", + description=u"Remove the saved checkpoint from 'incremental' (-i) mode.", + usage=u"code42 securitydata clear-checkpoint ", + ) add_profile_name_arg(parser) parser.set_defaults(func=clear_checkpoint) diff --git a/src/code42cli/securitydata/subcommands/print_out.py b/src/code42cli/securitydata/subcommands/print_out.py index cf10c8185..c518bae2c 100644 --- a/src/code42cli/securitydata/subcommands/print_out.py +++ b/src/code42cli/securitydata/subcommands/print_out.py @@ -11,7 +11,11 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser = subcommand_parser.add_parser("print") + parser = subcommand_parser.add_parser( + u"print", + description=u"Print file events to stdout", + usage=u"code42 securitydata print ", + ) parser.set_defaults(func=print_out) search_args.add_arguments_to_parser(parser) securitydata_main_args.add_arguments_to_parser(parser) diff --git a/src/code42cli/securitydata/subcommands/send_to.py b/src/code42cli/securitydata/subcommands/send_to.py index 592c3416e..67c4ab479 100644 --- a/src/code42cli/securitydata/subcommands/send_to.py +++ b/src/code42cli/securitydata/subcommands/send_to.py @@ -12,7 +12,11 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser """ - parser = subcommand_parser.add_parser("send-to") + parser = subcommand_parser.add_parser( + u"send-to", + description=u"Send file events to the given server address.", + usage=u"code42 securitydata send-to ", + ) parser.set_defaults(func=send_to) _add_server_arg(parser) _add_protocol_arg(parser) @@ -28,16 +32,18 @@ def send_to(args): def _add_server_arg(parser): - parser.add_argument(action="store", dest="server", help="The server address to send output to.") + parser.add_argument( + action=u"store", dest=u"server", help=u"The server address to send output to." + ) def _add_protocol_arg(parser): parser.add_argument( - "-p", - "--protocol", - action="store", - dest="protocol", + u"-p", + u"--protocol", + action=u"store", + dest=u"protocol", choices=ServerProtocol(), default=ServerProtocol.UDP, - help="Protocol used to send logs to server.", + help=u"Protocol used to send logs to server.", ) diff --git a/src/code42cli/securitydata/subcommands/write_to.py b/src/code42cli/securitydata/subcommands/write_to.py index e190bc783..1bae7b30f 100644 --- a/src/code42cli/securitydata/subcommands/write_to.py +++ b/src/code42cli/securitydata/subcommands/write_to.py @@ -11,7 +11,11 @@ def init(subcommand_parser): Args: subcommand_parser: The subparsers group created by the parent parser. """ - parser = subcommand_parser.add_parser("write-to") + parser = subcommand_parser.add_parser( + u"write-to", + description=u"Write file events to the file with the given name.", + usage=u"code42 securitydata write-to ", + ) parser.set_defaults(func=write_to) _add_filename_subcommand(parser) search_args.add_arguments_to_parser(parser) diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index b5b54e49f..2fe151dec 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -1,9 +1,5 @@ import pytest -from py42.sdk.alert_query import Actor -from py42.sdk.file_event_query.device_query import DeviceUsername -from py42.sdk.file_event_query.event_query import Source, EventTimestamp -from py42.sdk.file_event_query.exposure_query import ExposureType, ProcessOwner, TabURL -from py42.sdk.file_event_query.file_query import FilePath, FileName, SHA256, MD5 +from py42.sdk.queries.fileevents.filters import * import code42cli.securitydata.extraction as extraction_module from code42cli.securitydata.options import ExposureType as ExposureTypeOptions @@ -13,7 +9,7 @@ @pytest.fixture(autouse=True) def mock_42(mocker): - return mocker.patch("py42.sdk.SDK.create_using_local_account") + return mocker.patch("py42.sdk.from_local_account") @pytest.fixture diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 224d2da85..50de426f5 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,6 +1,6 @@ import pytest -from py42 import debug_level -from py42 import settings +import py42.sdk +import py42.sdk.settings.debug as debug from code42cli.sdk_client import create_sdk, validate_connection from .conftest import create_mock_profile @@ -8,11 +8,11 @@ @pytest.fixture def mock_sdk_factory(mocker): - return mocker.patch("py42.sdk.SDK.create_using_local_account") + return mocker.patch("py42.sdk.from_local_account") @pytest.fixture -def error_sdk_factory(mocker, mock_sdk_factory): +def error_sdk_factory(mock_sdk_factory): def side_effect(): raise Exception() @@ -29,7 +29,7 @@ def test_create_sdk_when_py42_exception_occurs_causes_exit(error_sdk_factory): def test_create_sdk_when_told_to_debug_turns_on_debug(mock_sdk_factory): profile = create_mock_profile() create_sdk(profile, True) - assert settings.debug_level == debug_level.DEBUG + assert py42.sdk.settings.debug.level == debug.DEBUG def test_validate_connection_when_creating_sdk_raises_returns_false(error_sdk_factory): From 5a0964c0f6e95f835a0d8843568cfc5404a36e69 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 25 Mar 2020 09:53:06 -0500 Subject: [PATCH 017/349] Fix date in cl (#19) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89d6af99a..2104d9c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## 0.4.3 - 2020-03-16 +## 0.4.3 - 2020-03-17 ### Added From 80d7a8ab38bb9fd1f6c1ff5091753a1a2248af11 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 1 Apr 2020 20:29:25 +0530 Subject: [PATCH 018/349] INTEG-946 Changed upper boundary of timestamp to include microseconds to end_date (#21) * INTEG-946 Changed upper boundary of timestamp to include microseconds when time is not specified * INTEG-946 : Refactor change method name and refactor parse_timestamp * INTEG-946 Added Changelog --- CHANGELOG.md | 4 ++ src/code42cli/securitydata/date_helper.py | 46 +++++++++++++++-------- tests/securitydata/test_date_helper.py | 2 +- tests/securitydata/test_extraction.py | 2 +- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2104d9c52..d4dbef6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +###Fixed + +- Add milliseconds to end timestamp, to represent end of day with milliseconds precision. + ## 0.4.3 - 2020-03-17 ### Added diff --git a/src/code42cli/securitydata/date_helper.py b/src/code42cli/securitydata/date_helper.py index d12875c34..65ab85b2d 100644 --- a/src/code42cli/securitydata/date_helper.py +++ b/src/code42cli/securitydata/date_helper.py @@ -15,40 +15,54 @@ def create_event_timestamp_filter(begin_date=None, end_date=None): begin_date: The begin date for the range. end_date: The end date for the range. """ - end_date = _get_end_date_with_eod_time_if_needed(end_date) + if begin_date and end_date: - return _create_in_range_filter(begin_date, end_date) + min_timestamp = _parse_min_timestamp(begin_date) + max_timestamp = _parse_max_timestamp(end_date) + return _create_in_range_filter(min_timestamp, max_timestamp) elif begin_date and not end_date: - return _create_on_or_after_filter(begin_date) + min_timestamp = _parse_min_timestamp(begin_date) + return _create_on_or_after_filter(min_timestamp) elif end_date and not begin_date: - return _create_on_or_before_filter(end_date) + max_timestamp = _parse_max_timestamp(end_date) + return _create_on_or_before_filter(max_timestamp) + + +def _parse_max_timestamp(end_date): + if len(end_date) == 1: + end_date = _get_end_date_with_eod_time_if_needed(end_date) + max_time = _parse_timestamp(end_date) + max_time = _add_milliseconds(max_time) + else: + max_time = _parse_timestamp(end_date) + + return convert_datetime_to_timestamp(max_time) + + +def _add_milliseconds(max_time): + return max_time + timedelta(milliseconds=999) -def _create_in_range_filter(begin_date, end_date): - min_timestamp = _parse_min_timestamp(begin_date) - max_timestamp = _parse_timestamp(end_date) +def _create_in_range_filter(min_timestamp, max_timestamp): _verify_timestamp_order(min_timestamp, max_timestamp) return EventTimestamp.in_range(min_timestamp, max_timestamp) -def _create_on_or_after_filter(begin_date): - min_timestamp = _parse_min_timestamp(begin_date) +def _create_on_or_after_filter(min_timestamp): return EventTimestamp.on_or_after(min_timestamp) -def _create_on_or_before_filter(end_date): - max_timestamp = _parse_timestamp(end_date) +def _create_on_or_before_filter(max_timestamp): return EventTimestamp.on_or_before(max_timestamp) def _get_end_date_with_eod_time_if_needed(end_date): - if end_date and len(end_date) == 1: - return end_date[0], "23:59:59" - return end_date + return end_date[0], "23:59:59" def _parse_min_timestamp(begin_date_str): - min_timestamp = _parse_timestamp(begin_date_str) + min_time = _parse_timestamp(begin_date_str) + min_timestamp = convert_datetime_to_timestamp(min_time) boundary_date = datetime.utcnow() - timedelta(days=_MAX_LOOK_BACK_DAYS) boundary = convert_datetime_to_timestamp(boundary_date) if min_timestamp and min_timestamp < boundary: @@ -68,9 +82,9 @@ def _parse_timestamp(date_and_time): date_str = _join_date_and_time(date_and_time) date_format = u"%Y-%m-%d" if len(date_and_time) == 1 else u"%Y-%m-%d %H:%M:%S" time = datetime.strptime(date_str, date_format) + return time except ValueError: raise ValueError(_FORMAT_VALUE_ERROR_MESSAGE) - return convert_datetime_to_timestamp(time) def _join_date_and_time(date_and_time): diff --git a/tests/securitydata/test_date_helper.py b/tests/securitydata/test_date_helper.py index 1094c2779..cb458ed2d 100644 --- a/tests/securitydata/test_date_helper.py +++ b/tests/securitydata/test_date_helper.py @@ -37,7 +37,7 @@ def test_create_event_timestamp_filter_when_given_begin_with_time_builds_expecte def test_create_event_timestamp_filter_when_given_end_builds_expected_query(): ts_range = create_event_timestamp_filter(begin_date_list, end_date_list) actual = get_filter_value_from_json(ts_range, filter_index=1) - expected = "{0}T23:59:59.000Z".format(end_date_list[0]) + expected = "{0}T23:59:59.999Z".format(end_date_list[0]) assert actual == expected diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index 2fe151dec..60fb7abd7 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -213,7 +213,7 @@ def test_extract_when_given_end_date_uses_expected_query(logger, namespace_with_ namespace_with_begin.end_date = get_test_date_str(days_ago=10) extraction_module.extract(logger, namespace_with_begin) actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T23:59:59.000Z".format(namespace_with_begin.end_date) + expected = "{0}T23:59:59.999Z".format(namespace_with_begin.end_date) assert actual == expected From 062e66292497b82a3f405f6968c9863174379b5a Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 1 Apr 2020 20:38:52 +0530 Subject: [PATCH 019/349] Added code to write to STDERR when no results found (#22) * Added code to write to STDERR when no results found * INTEG-935 added changelog --- CHANGELOG.md | 8 ++++++++ src/code42cli/__version__.py | 2 +- src/code42cli/securitydata/extraction.py | 8 +++++++- src/code42cli/util.py | 4 ++++ tests/securitydata/test_extraction.py | 18 ++++++++++++++++++ 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4dbef6c9..d87de4e9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 0.4.4 - 2020-04-01 + +###Added + +- Added message to STDERR when no results are found + ###Fixed - Add milliseconds to end timestamp, to represent end of day with milliseconds precision. +## Unreleased + ## 0.4.3 - 2020-03-17 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index f6b7e267c..cd1ee63b7 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.4.3" +__version__ = "0.4.4" diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/securitydata/extraction.py index 7b790ba0b..b71843583 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/securitydata/extraction.py @@ -15,9 +15,10 @@ from code42cli.securitydata.cursor_store import FileEventCursorStore from code42cli.securitydata.logger_factory import get_error_logger from code42cli.securitydata.options import ExposureType as ExposureTypeOptions -from code42cli.util import print_error, print_bold, is_interactive +from code42cli.util import print_error, print_bold, is_interactive, print_to_stderr _EXCEPTIONS_OCCURRED = False +_TOTAL_EVENTS = 0 def extract(output_logger, args): @@ -142,6 +143,8 @@ def handle_error(exception): def handle_response(response): response_dict = json.loads(response.text) events = response_dict.get(u"fileEvents") + global _TOTAL_EVENTS + _TOTAL_EVENTS += len(events) for event in events: output_logger.info(event) @@ -170,6 +173,9 @@ def _verify_compatibility_with_advanced_query(key, val): def _handle_result(): if is_interactive() and _EXCEPTIONS_OCCURRED: print_error(u"View exceptions that occurred at [HOME]/.code42cli/log/code42_errors.") + global _TOTAL_EVENTS + if not _TOTAL_EVENTS: + print_to_stderr(u"No results found\n") def _try_append_exposure_types_filter(filters, args): diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 2c0420508..4274c852d 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -70,3 +70,7 @@ def get_url_parts(url_str): if len(parts) > 1 and parts[1] != u"": port = int(parts[1]) return parts[0], port + + +def print_to_stderr(error_text): + sys.stderr.write(error_text) diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py index 60fb7abd7..87f5ffb27 100644 --- a/tests/securitydata/test_extraction.py +++ b/tests/securitydata/test_extraction.py @@ -511,3 +511,21 @@ def sdk_side_effect(self, *args): extraction_module.extract(logger, namespace_with_begin) assert extraction_module._EXCEPTIONS_OCCURRED + + +def test_extract_when_no_results_are_found_prints_error_to_stderr( + mocker, logger, namespace_with_begin, extractor +): + mock_error = mocker.patch("code42cli.securitydata.extraction.print_to_stderr") + extraction_module._TOTAL_EVENTS = 0 + extraction_module.extract(logger, namespace_with_begin) + assert mock_error.call_count + + +def test_extract_when_results_are_found_does_not_print_error_to_stderr( + mocker, logger, namespace_with_begin, extractor +): + mock_error = mocker.patch("code42cli.securitydata.extraction.print_to_stderr") + extraction_module._TOTAL_EVENTS = 1 + extraction_module.extract(logger, namespace_with_begin) + assert not mock_error.call_count From 54b6b0e821fbf753c7bf2f2483bc3c6b6367f345 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Wed, 8 Apr 2020 15:29:53 -0500 Subject: [PATCH 020/349] working cmd pattern (#23) --- CHANGELOG.md | 16 +- CONTRIBUTING.md | 48 +- README.md | 71 +- setup.py | 9 +- src/code42cli/args.py | 107 +++ src/code42cli/arguments.py | 25 - src/{ => code42cli/cmds}/__init__.py | 0 src/code42cli/cmds/profile.py | 130 +++ .../securitydata}/__init__.py | 0 .../{ => cmds}/securitydata/date_helper.py | 4 +- src/code42cli/cmds/securitydata/enums.py | 80 ++ .../{ => cmds}/securitydata/extraction.py | 86 +- .../{ => cmds}/securitydata/logger_factory.py | 12 +- src/code42cli/cmds/securitydata/main.py | 191 +++++ .../{securitydata => cmds/shared}/__init__.py | 0 .../shared}/cursor_store.py | 0 src/code42cli/commands.py | 133 ++++ src/code42cli/{profile => }/config.py | 49 +- src/code42cli/invoker.py | 69 ++ src/code42cli/main.py | 51 +- src/code42cli/parser.py | 108 +++ src/code42cli/password.py | 38 + src/code42cli/profile.py | 88 +++ src/code42cli/profile/password.py | 119 --- src/code42cli/profile/profile.py | 302 ------- src/code42cli/sdk_client.py | 1 - src/code42cli/securitydata/arguments/main.py | 31 - .../securitydata/arguments/search.py | 200 ----- src/code42cli/securitydata/main.py | 26 - src/code42cli/securitydata/options.py | 40 - .../subcommands/clear_checkpoint.py | 26 - .../securitydata/subcommands/print_out.py | 28 - .../securitydata/subcommands/send_to.py | 49 -- .../securitydata/subcommands/write_to.py | 35 - src/code42cli/util.py | 9 +- test.txt | 741 ++++++++++++++++++ .../arguments => tests/cmds}/__init__.py | 0 .../cmds/securitydata}/__init__.py | 0 tests/cmds/securitydata/conftest.py | 78 ++ .../securitydata/test_cursor_store.py | 7 +- .../securitydata/test_date_helper.py | 5 +- tests/cmds/securitydata/test_extraction.py | 554 +++++++++++++ .../securitydata/test_logger_factory.py | 5 +- tests/cmds/securitydata/test_main.py | 34 + tests/cmds/test_profile.py | 168 ++++ tests/conftest.py | 96 +-- tests/profile/__init__.py | 0 tests/profile/conftest.py | 3 - tests/profile/test_password.py | 163 ---- tests/profile/test_profile.py | 294 ------- tests/securitydata/__init__.py | 0 tests/securitydata/conftest.py | 20 - tests/securitydata/subcommands/__init__.py | 0 tests/securitydata/subcommands/conftest.py | 34 - .../subcommands/test_clear_checkpoint.py | 43 - .../subcommands/test_print_out.py | 42 - .../securitydata/subcommands/test_send_to.py | 73 -- .../securitydata/subcommands/test_write_to.py | 72 -- tests/securitydata/test_extraction.py | 531 ------------- tests/test_args.py | 72 ++ tests/test_commands.py | 215 +++++ tests/{profile => }/test_config.py | 144 ++-- tests/test_invoker.py | 59 ++ tests/test_main.py | 88 +-- tests/test_parser.py | 115 +++ tests/test_password.py | 100 +++ tests/test_profile.py | 148 ++++ tests/test_sdk_client.py | 12 +- tests/test_util.py | 2 +- tox.ini | 2 +- 70 files changed, 3598 insertions(+), 2503 deletions(-) create mode 100644 src/code42cli/args.py delete mode 100644 src/code42cli/arguments.py rename src/{ => code42cli/cmds}/__init__.py (100%) create mode 100644 src/code42cli/cmds/profile.py rename src/code42cli/{profile => cmds/securitydata}/__init__.py (100%) rename src/code42cli/{ => cmds}/securitydata/date_helper.py (99%) create mode 100644 src/code42cli/cmds/securitydata/enums.py rename src/code42cli/{ => cmds}/securitydata/extraction.py (65%) rename src/code42cli/{ => cmds}/securitydata/logger_factory.py (96%) create mode 100644 src/code42cli/cmds/securitydata/main.py rename src/code42cli/{securitydata => cmds/shared}/__init__.py (100%) rename src/code42cli/{securitydata => cmds/shared}/cursor_store.py (100%) create mode 100644 src/code42cli/commands.py rename src/code42cli/{profile => }/config.py (73%) create mode 100644 src/code42cli/invoker.py create mode 100644 src/code42cli/parser.py create mode 100644 src/code42cli/password.py create mode 100644 src/code42cli/profile.py delete mode 100644 src/code42cli/profile/password.py delete mode 100644 src/code42cli/profile/profile.py delete mode 100644 src/code42cli/securitydata/arguments/main.py delete mode 100644 src/code42cli/securitydata/arguments/search.py delete mode 100644 src/code42cli/securitydata/main.py delete mode 100644 src/code42cli/securitydata/options.py delete mode 100644 src/code42cli/securitydata/subcommands/clear_checkpoint.py delete mode 100644 src/code42cli/securitydata/subcommands/print_out.py delete mode 100644 src/code42cli/securitydata/subcommands/send_to.py delete mode 100644 src/code42cli/securitydata/subcommands/write_to.py create mode 100644 test.txt rename {src/code42cli/securitydata/arguments => tests/cmds}/__init__.py (100%) rename {src/code42cli/securitydata/subcommands => tests/cmds/securitydata}/__init__.py (100%) create mode 100644 tests/cmds/securitydata/conftest.py rename tests/{ => cmds}/securitydata/test_cursor_store.py (94%) rename tests/{ => cmds}/securitydata/test_date_helper.py (95%) create mode 100644 tests/cmds/securitydata/test_extraction.py rename tests/{ => cmds}/securitydata/test_logger_factory.py (99%) create mode 100644 tests/cmds/securitydata/test_main.py create mode 100644 tests/cmds/test_profile.py delete mode 100644 tests/profile/__init__.py delete mode 100644 tests/profile/conftest.py delete mode 100644 tests/profile/test_password.py delete mode 100644 tests/profile/test_profile.py delete mode 100644 tests/securitydata/__init__.py delete mode 100644 tests/securitydata/conftest.py delete mode 100644 tests/securitydata/subcommands/__init__.py delete mode 100644 tests/securitydata/subcommands/conftest.py delete mode 100644 tests/securitydata/subcommands/test_clear_checkpoint.py delete mode 100644 tests/securitydata/subcommands/test_print_out.py delete mode 100644 tests/securitydata/subcommands/test_send_to.py delete mode 100644 tests/securitydata/subcommands/test_write_to.py delete mode 100644 tests/securitydata/test_extraction.py create mode 100644 tests/test_args.py create mode 100644 tests/test_commands.py rename tests/{profile => }/test_config.py (68%) create mode 100644 tests/test_invoker.py create mode 100644 tests/test_parser.py create mode 100644 tests/test_password.py create mode 100644 tests/test_profile.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d87de4e9d..f7a561514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- `code42 profile create` command. + +### Removed + +- `code42 profile set` command. Use `code42 profile create` instead. + ## 0.4.4 - 2020-04-01 -###Added +### Added - Added message to STDERR when no results are found -###Fixed +### Fixed - Add milliseconds to end timestamp, to represent end of day with milliseconds precision. -## Unreleased - ## 0.4.3 - 2020-03-17 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7a41e6de..9c4e326a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ $ pre-commit install ``` This will set up a pre-commit hook that will automatically format your code to our desired styles whenever you commit. -It requires python 3.6 to run, so be sure to have a python 3.6 executable of some kind in your PATH when you commit. +It requires python 3.6+ to run, so be sure to have a qualifying python executable in your PATH when you commit. ## General @@ -75,3 +75,49 @@ Example: ```python def test_add_one_and_one_equals_two(): ``` + +### Adding a new command + +See class documentation on the [Command](src/code42cli/commands.py) class for an explanation of its constructor parameters. + +1. If you are creating a new top-level command, create a new instance of `Command` and add it to the list returned + by `_load_top_commands()` function in `code42cli.main`. + +2. If you are creating a new subcommand, find the top-level command that this will be a subcommand of in + `_load_top_commands()` in `code42cli.main` and navigate to the function assigned to be its subcommand loader. + Then, add a new instance of `Command` to the list returned by that function. + +3. For commands that actually are executed (rather than just being groups), you will add a `handler` function as a constructor parameter. + This will be the function that you want to execute when your command is run. + * _Positional_ arguments of the handler will automatically become _required_ cli arguments + * _Keyword_ arguments of the handler will automatically become _optional_ cli arguments + * the cli argument name will be the same as the handler param name except with `_` replaced with `-`, and prefixed with `--` if optional + + For example, consider the following python function: + + ```python + def handler_example(one, two, three=None, four=None): + pass + ``` + + When the above function is supplied as a `Command`'s `handler` parameter, the result will be a command that can be executed as follows + (assuming `cmd` is the name given to the command): + + ```bash + $ code42 cmd oneval twoval --three threeval --four fourval + ``` + +4. To add descriptions to your cli arguments to appear in the help text, your command takes a function as the `arg_customizer` parameter. + The entire [`ArgConfigCollection`](src/code42cli/args.py) that was automatically created is supplied as the only argument to this function + and can be modified by it. See `code42cli.cmds.profile._load_profile_create_descriptions` for an example of this. + +5. If one of your handler's parameters is named `sdk`, you will automatically get a `--profile` argument available in the cli and the `sdk` parameter + will automatically contain an instance of `py42.sdk.SDKClient` that was created with the given (or default) profile. + - A cli parameter named `--sdk` will _not_ be added in this case. + +6. If you have an `sdk` parameter, a parameter named `profile` will automatically contain the info of the profile that was used to create the sdk. + - A parameter named `profile` behaves normally if you do not also have a parameter named `sdk`. + +7. Each command accepts a `use_single_arg_obj` bool in its constructor. If set to true, this will instead cause the handler to be called with a single object + containing all of the args as attributes, which will be passed to a variable named `args` in your handler. Since your handler will only contain the parameter `args`, + the names of your cli parameters need to built manually in your `arg_customizer` if you use this option. An example of this can be seen in `code42cli.cmds.securitydata.main`. diff --git a/README.md b/README.md index 3bd7055a2..bc326377f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Additionally, you can choose to only get events that Code42 previously did not o - Code42 Server 6.8.x+ ## Installation + Install the `code42` CLI using: ```bash @@ -19,39 +20,35 @@ $ python setup.py install ## Usage -First, set your profile: +First, create your profile: ```bash -code42 profile set --profile MY_FIRST_PROFILE -s https://example.authority.com -u security.admin@example.com +code42 profile create MY_FIRST_PROFILE https://example.authority.com security.admin@example.com ``` -The `--profile` flag is required the first time and it takes a name. -On subsequent uses of `set`, not specifying the profile will set the default profile. Your profile contains the necessary properties for logging into Code42 servers. -After running `code42 profile set`, the program prompts you about storing a password. +After running `code42 profile create`, the program prompts you about storing a password. If you agree, you are then prompted to input your password. -Your password is not stored in plain-text and is not shown when you do `code42 profile show`. +Your password is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that a password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each time you run a command. -For development purposes, you may need to ignore ssl errors. If you need to do this, do: -```bash -code42 profile set --disable-ssl-errors -``` +For development purposes, you may need to ignore ssl errors. If you need to do this, use the `--disable-ssl-errors` option when creating your profile: -To re-enable SSL errors, do: ```bash -code42 profile set --enable-ssl-errors +code42 profile create MY_FIRST_PROFILE https://example.authority.com security.admin@example.com --disable-ssl-errors ``` You can add multiple profiles with different names and the change the default profile with the `use` command: + ```bash code42 profile use MY_SECOND_PROFILE ``` -When the `--profile` flag is available on other commands, such as those in `securitydata`, -it will use that profile instead of the default one. + +When the `--profile` flag is available on other commands, such as those in `securitydata`, it will use that profile instead of the default one. To see all your profiles, do: + ```bash code42 profile list ``` @@ -62,6 +59,7 @@ Using the CLI, you can query for events and send them to three possible destinat * A server, such as SysLog To print events to stdout, do: + ```bash code42 securitydata print -b 2020-02-02 ``` @@ -72,67 +70,74 @@ To specify a time, do: ```bash code42 securitydata print -b 2020-02-02 12:51 ``` + Begin date will be ignored if provided on subsequent queries using `-i`. Use different format with `-f`: + ```bash code42 securitydata print -b 2020-02-02 -f CEF ``` + The available formats are CEF, JSON, and RAW-JSON. To write events to a file, do: + ```bash code42 securitydata write-to filename.txt -b 2020-02-02 ``` To send events to a server, do: + ```bash code42 securitydata send-to syslog.company.com -p TCP -b 2020-02-02 ``` To only get events that Code42 previously did not observe since you last recorded a checkpoint, use the `-i` flag. + ```bash code42 securitydata send-to syslog.company.com -i ``` + This is only guaranteed if you did not change your query. To send events to a server using a specific profile, do: + ```bash code42 securitydata send-to --profile PROFILE_FOR_RECURRING_JOB syslog.company.com -b 2020-02-02 -f CEF -i ``` You can also use wildcard for queries, but note, if they are not in quotes, you may get unexpected behavior. + ```bash code42 securitydata print --actor "*" ``` - Each destination-type subcommand shares query parameters -* `-t` (exposure types) -* `-b` (begin date) -* `-e` (end date) -* `--c42username` -* `--actor` -* `--md5` -* `--sha256` -* `--source` -* `--filename` -* `--filepath` -* `--processOwner` -* `--tabURL` -* `--include-non-exposure` (does not work with `-t`) -* `--advanced-query` (raw JSON query) + +- `-t` (exposure types) +- `-b` (begin date) +- `-e` (end date) +- `--c42username` +- `--actor` +- `--md5` +- `--sha256` +- `--source` +- `--filename` +- `--filepath` +- `--processOwner` +- `--tabURL` +- `--include-non-exposure` (does not work with `-t`) +- `--advanced-query` (raw JSON query) You cannot use other query parameters if you use `--advanced-query`. To learn more about acceptable arguments, add the `-h` flag to `code42` or any of the destination-type subcommands. - -# Known Issues +## Known Issues Only the first 10,000 of each set of events containing the exact same insertion timestamp is reported. - -# Troubleshooting +## Troubleshooting If you keep getting prompted for your password, try resetting with `code42 profile reset-pw`. If that doesn't work, delete your credentials file located at ~/.code42cli or the entry in keychain. diff --git a/setup.py b/setup.py index 288e344bd..9576b451d 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ +from codecs import open from os import path from setuptools import find_packages, setup -from codecs import open here = path.abspath(path.dirname(__file__)) @@ -20,7 +20,12 @@ packages=find_packages("src"), package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", - install_requires=["c42eventextractor==0.2.2", "keyring==18.0.1","py42==0.6.0"], + install_requires=[ + "c42eventextractor==0.2.2", + "keyring==18.0.1", + "keyrings.alt==3.2.0", + "py42==0.6.0", + ], license="MIT", include_package_data=True, zip_safe=False, diff --git a/src/code42cli/args.py b/src/code42cli/args.py new file mode 100644 index 000000000..b5628e541 --- /dev/null +++ b/src/code42cli/args.py @@ -0,0 +1,107 @@ +import inspect + +PROFILE_HELP = u"The name of the Code42 profile use when executing this command." + + +class ArgConfig(object): + """Stores a set of argparse commands for later use by a command.""" + + def __init__(self, *args, **kwargs): + self._settings = {} + self._settings[u"action"] = kwargs.get(u"action") + self._settings[u"choices"] = kwargs.get(u"choices") + self._settings[u"default"] = kwargs.get(u"default") + self._settings[u"help"] = kwargs.get(u"help") + self._settings[u"options_list"] = list(args) + self._settings[u"nargs"] = kwargs.get(u"nargs") + + @property + def settings(self): + return self._settings + + def set_choices(self, choices): + self._settings[u"choices"] = choices + + def set_help(self, help): + self._settings[u"help"] = help + + def add_short_option_name(self, short_name): + self._settings[u"options_list"].append(short_name) + + +class ArgConfigCollection(object): + def __init__(self): + self._arg_configs = {} + + @property + def arg_configs(self): + return self._arg_configs + + def append(self, name, arg_config): + self._arg_configs[name] = arg_config + + def extend(self, arg_config_dict): + self.arg_configs.update(arg_config_dict) + + +def get_auto_arg_configs(handler): + """Looks at the parameter names of `handler` and builds an `ArgConfigCollection` containing argparse + parameters based on them.""" + arg_configs = ArgConfigCollection() + if callable(handler): + # get the number of positional and keyword args + argspec = inspect.getargspec(handler) + num_args = len(argspec.args) + num_kw_args = len(argspec.defaults) if argspec.defaults else 0 + + for arg_position, key in enumerate(argspec.args): + # do not create cli parameters for arguments named "sdk", "args", or "kwargs" + if not key in [u"sdk", u"args", u"kwargs"]: + arg_config = _create_auto_args_config( + arg_position, key, argspec, num_args, num_kw_args + ) + _set_smart_defaults(arg_config) + arg_configs.append(key, arg_config) + + if u"sdk" in argspec.args: + _build_sdk_arg_configs(arg_configs) + + return arg_configs + + +def _create_auto_args_config(arg_position, key, argspec, num_args, num_kw_args): + default = None + param_name = key.replace(u"_", u"-") + difference = num_args - num_kw_args + last_positional_arg_idx = difference - 1 + # postional arguments will come first, so if the arg position + # is greater than the index of the last positional arg, it's a kwarg. + if arg_position > last_positional_arg_idx: + # this is a keyword arg, treat it as an optional cli arg. + default_value = argspec.defaults[arg_position - difference] + option_names = [u"--{}".format(param_name)] + default = default_value + else: + # this is a positional arg, treat it as a required cli arg. + option_names = [param_name] + return ArgConfig(*option_names, default=default) + + +def _set_smart_defaults(arg_config): + default = arg_config.settings.get(u"default") + # make a parameter allow lists as input if its default value is a list, + # e.g. --my-param one two three four + nargs = u"+" if type(default) == list else None + arg_config.settings[u"nargs"] = nargs + # make the param not require a value (e.g. --enable) if the default value of + # the param is a bool. + if type(default) == bool: + arg_config.settings[u"action"] = u"store_{}".format(default).lower() + + +def _build_sdk_arg_configs(arg_config_collection): + """Add extra cli parameters that will always be relevant when a handler needs the sdk.""" + profile = ArgConfig(u"--profile", help=PROFILE_HELP) + debug = ArgConfig(u"-d", u"--debug", action=u"store_true", help=u"Turn on Debug logging.") + extras = {u"profile": profile, u"debug": debug} + arg_config_collection.extend(extras) diff --git a/src/code42cli/arguments.py b/src/code42cli/arguments.py deleted file mode 100644 index e7f4d4f83..000000000 --- a/src/code42cli/arguments.py +++ /dev/null @@ -1,25 +0,0 @@ -PROFILE_NAME_KEY = u"profile_name" -PROFILE_HELP_MESSAGE = ( - u"The name of the profile containing your Code42 username and authority host address." -) - - -def add_arguments_to_parser(parser): - add_debug_arg(parser) - add_profile_name_arg(parser) - - -def add_debug_arg(parser): - parser.add_argument( - u"-d", - u"--debug", - dest=u"is_debug_mode", - action=u"store_true", - help=u"Turn on Debug logging.", - ) - - -def add_profile_name_arg(parser): - parser.add_argument( - u"--profile", action=u"store", dest=PROFILE_NAME_KEY, help=PROFILE_HELP_MESSAGE - ) diff --git a/src/__init__.py b/src/code42cli/cmds/__init__.py similarity index 100% rename from src/__init__.py rename to src/code42cli/cmds/__init__.py diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py new file mode 100644 index 000000000..8d2723ff6 --- /dev/null +++ b/src/code42cli/cmds/profile.py @@ -0,0 +1,130 @@ +from __future__ import print_function + +from getpass import getpass + +import code42cli.profile as cliprofile +from code42cli.args import PROFILE_HELP +from code42cli.commands import Command +from code42cli.sdk_client import validate_connection +from code42cli.util import does_user_agree, print_error, print_no_existing_profile_message + + +def load_subcommands(): + """Sets up the `profile` subcommand with all of its subcommands.""" + usage_prefix = u"code42 profile" + + show = Command( + u"show", + u"Print the details of a profile.", + u"{} {}".format(usage_prefix, u"show "), + handler=show_profile, + arg_customizer=_load_profile_description, + ) + + list_all = Command( + u"list", + u"Show all existing stored profiles.", + u"{} {}".format(usage_prefix, u"list"), + handler=list_profiles, + ) + + use = Command( + u"use", + u"Set a profile as the default.", + u"{} {}".format(usage_prefix, u"use "), + handler=use_profile, + ) + + reset_pw = Command( + u"reset-pw", + u"Change the stored password for a profile.", + u"{} {}".format(usage_prefix, u"reset-pw "), + handler=prompt_for_password_reset, + arg_customizer=_load_profile_description, + ) + + create = Command( + u"create", + u"Create profile settings. The first profile created will be the default.", + u"{} {}".format(usage_prefix, u"create "), + handler=create_profile, + arg_customizer=_load_profile_create_descripions, + ) + + return [show, list_all, use, reset_pw, create] + + +def show_profile(profile=None): + """Prints the given profile to stdout.""" + c42profile = cliprofile.get_profile(profile) + print(u"\n{0}:".format(c42profile.name)) + print(u"\t* username = {}".format(c42profile.username)) + print(u"\t* authority url = {}".format(c42profile.authority_url)) + print(u"\t* ignore-ssl-errors = {}".format(c42profile.ignore_ssl_errors)) + if cliprofile.get_stored_password(c42profile.name) is not None: + print(u"\t* A password is set.") + print(u"") + + +def create_profile(profile, server, username, disable_ssl_errors=False): + """Sets the given profile using command line arguments.""" + if cliprofile.profile_exists(profile): + print_error(u"A profile named {} already exists.".format(profile)) + exit(1) + + cliprofile.create_profile(profile, server, username, disable_ssl_errors) + _prompt_for_allow_password_set(profile) + + +def prompt_for_password_reset(profile=None): + """Securely prompts for your password and then stores it using keyring.""" + c42profile = cliprofile.get_profile(profile) + new_password = getpass() + + if not validate_connection(c42profile.authority_url, c42profile.username, new_password): + print_error( + u"Your credentials failed to validate, so your password was not stored." + u"Check your network connection and the spelling of your username and server URL." + ) + exit(1) + cliprofile.set_password(new_password, c42profile.name) + + +def list_profiles(*args): + """Lists all profiles that exist for this OS user.""" + profiles = cliprofile.get_all_profiles() + if not profiles: + print_no_existing_profile_message() + return + for profile in profiles: + print(profile) + + +def use_profile(profile): + """Changes the default profile to the given one.""" + cliprofile.switch_default_profile(profile) + + +def _load_profile_description(argument_collection): + profile = argument_collection.arg_configs["profile"] + profile.set_help(PROFILE_HELP) + + +def _load_profile_create_descripions(argument_collection): + profile = argument_collection.arg_configs["profile"] + server = argument_collection.arg_configs["server"] + username = argument_collection.arg_configs["username"] + + disable_ssl_errors = argument_collection.arg_configs["disable_ssl_errors"] + profile.set_help(u"The name to give the profile being created.") + server.set_help(u"The url and port of the Code42 server.") + username.set_help(u"The username of the Code42 API user.") + disable_ssl_errors.set_help( + u"For development purposes, do not validate the SSL certificates of Code42 servers." + u"This is not recommended unless it is required." + ) + + +def _prompt_for_allow_password_set(profile_name): + if does_user_agree(u"Would you like to set a password? (y/n): "): + prompt_for_password_reset(profile_name) diff --git a/src/code42cli/profile/__init__.py b/src/code42cli/cmds/securitydata/__init__.py similarity index 100% rename from src/code42cli/profile/__init__.py rename to src/code42cli/cmds/securitydata/__init__.py diff --git a/src/code42cli/securitydata/date_helper.py b/src/code42cli/cmds/securitydata/date_helper.py similarity index 99% rename from src/code42cli/securitydata/date_helper.py rename to src/code42cli/cmds/securitydata/date_helper.py index 65ab85b2d..6a9eb8c6c 100644 --- a/src/code42cli/securitydata/date_helper.py +++ b/src/code42cli/cmds/securitydata/date_helper.py @@ -1,6 +1,5 @@ -from datetime import datetime, timedelta - from c42eventextractor.common import convert_datetime_to_timestamp +from datetime import datetime, timedelta from py42.sdk.queries.fileevents.filters.event_filter import EventTimestamp _MAX_LOOK_BACK_DAYS = 90 @@ -10,7 +9,6 @@ def create_event_timestamp_filter(begin_date=None, end_date=None): """Creates a `py42.sdk.file_event_query.event_query.EventTimestamp` filter using the given dates. Returns None if not given a begin_date or an end_date. - Args: begin_date: The begin date for the range. end_date: The end date for the range. diff --git a/src/code42cli/cmds/securitydata/enums.py b/src/code42cli/cmds/securitydata/enums.py new file mode 100644 index 000000000..10abfe9f0 --- /dev/null +++ b/src/code42cli/cmds/securitydata/enums.py @@ -0,0 +1,80 @@ +IS_INCREMENTAL_KEY = u"incremental" + + +class OutputFormat(object): + CEF = "CEF" + JSON = "JSON" + RAW = "RAW-JSON" + + def __iter__(self): + return iter([self.CEF, self.JSON, self.RAW]) + + +class ExposureType(object): + SHARED_VIA_LINK = "SharedViaLink" + SHARED_TO_DOMAIN = "SharedToDomain" + APPLICATION_READ = "ApplicationRead" + CLOUD_STORAGE = "CloudStorage" + REMOVABLE_MEDIA = "RemovableMedia" + IS_PUBLIC = "IsPublic" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [ + self.SHARED_VIA_LINK, + self.SHARED_TO_DOMAIN, + self.APPLICATION_READ, + self.CLOUD_STORAGE, + self.REMOVABLE_MEDIA, + self.IS_PUBLIC, + ] + + +class ServerProtocol(object): + TCP = "TCP" + UDP = "UDP" + + def __iter__(self): + return iter([self.TCP, self.UDP]) + + +class SearchArguments(object): + ADVANCED_QUERY = u"advanced_query" + BEGIN_DATE = u"begin" + END_DATE = u"end" + EXPOSURE_TYPES = u"type" + C42USERNAME = u"c42username" + ACTOR = u"actor" + MD5 = u"md5" + SHA256 = u"sha256" + SOURCE = u"source" + FILENAME = u"filename" + FILEPATH = u"filepath" + PROCESS_OWNER = u"processOwner" + TAB_URL = u"tabURL" + INCLUDE_NON_EXPOSURE_EVENTS = u"include_non_exposure" + + def __iter__(self): + return iter( + [ + self.ADVANCED_QUERY, + self.BEGIN_DATE, + self.END_DATE, + self.EXPOSURE_TYPES, + self.C42USERNAME, + self.ACTOR, + self.MD5, + self.SHA256, + self.SOURCE, + self.FILENAME, + self.FILEPATH, + self.PROCESS_OWNER, + self.TAB_URL, + self.INCLUDE_NON_EXPOSURE_EVENTS, + ] + ) diff --git a/src/code42cli/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py similarity index 65% rename from src/code42cli/securitydata/extraction.py rename to src/code42cli/cmds/securitydata/extraction.py index b71843583..238d2b2f7 100644 --- a/src/code42cli/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -1,27 +1,26 @@ from __future__ import print_function import json - from c42eventextractor import FileEventHandlers from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.filters import * +import code42cli.cmds.securitydata.date_helper as date_helper +from code42cli.cmds.securitydata.enums import ( + ExposureType as ExposureTypeOptions, + IS_INCREMENTAL_KEY, + SearchArguments, +) +from code42cli.cmds.securitydata.logger_factory import get_error_logger +from code42cli.cmds.shared.cursor_store import FileEventCursorStore from code42cli.compat import str -from code42cli.profile.profile import get_profile -from code42cli.sdk_client import create_sdk -from code42cli.securitydata import date_helper as date_helper -from code42cli.securitydata.arguments.main import IS_INCREMENTAL_KEY -from code42cli.securitydata.arguments.search import SearchArguments -from code42cli.securitydata.cursor_store import FileEventCursorStore -from code42cli.securitydata.logger_factory import get_error_logger -from code42cli.securitydata.options import ExposureType as ExposureTypeOptions -from code42cli.util import print_error, print_bold, is_interactive, print_to_stderr +from code42cli.util import is_interactive, print_bold, print_error, print_to_stderr _EXCEPTIONS_OCCURRED = False _TOTAL_EVENTS = 0 -def extract(output_logger, args): +def extract(sdk, profile, output_logger, args): """Extracts file events using the given command-line arguments. Args: @@ -32,25 +31,23 @@ def extract(output_logger, args): args: Command line args used to build up file event query filters. """ - profile = get_profile(args.profile_name) store = _create_cursor_store(args, profile) filters = _get_filters(args, store) handlers = _create_event_handlers(output_logger, store) - sdk = create_sdk(profile, args.is_debug_mode) extractor = FileEventExtractor(sdk, handlers) - _call_extract(extractor, filters, args) + _call_extract(extractor, filters, args.advanced_query) _handle_result() def _create_cursor_store(args, profile): - if args.is_incremental: + if args.incremental: return FileEventCursorStore(profile.name) def _get_filters(args, cursor_store): if not _determine_if_advanced_query(args): _verify_begin_date_requirements(args, cursor_store) - _verify_exposure_types(args.exposure_types) + _verify_exposure_types(args.type) return _create_filters(args) else: return args.advanced_query @@ -69,7 +66,7 @@ def _determine_if_advanced_query(args): def _verify_begin_date_requirements(args, cursor_store): - if _begin_date_is_required(args, cursor_store) and not args.begin_date: + if _begin_date_is_required(args, cursor_store) and not args.begin: print_error(u"'begin date' is required.") print(u"") print_bold(u"Try using '-b' or '--begin'. Use `-h` for more info.") @@ -78,13 +75,13 @@ def _verify_begin_date_requirements(args, cursor_store): def _begin_date_is_required(args, cursor_store): - if not args.is_incremental: + if not args.incremental: return True is_required = cursor_store and cursor_store.get_stored_insertion_timestamp() is None # Ignore begin date when is incremental mode, it is not required, and it was passed an argument. - if not is_required and args.begin_date: - args.begin_date = None + if not is_required and args.begin: + args.begin = None return is_required @@ -100,25 +97,25 @@ def _verify_exposure_types(exposure_types): def _create_filters(args): filters = [] - event_timestamp_filter = _get_event_timestamp_filter(args) + event_timestamp_filter = _get_event_timestamp_filter(args.begin, args.end) not event_timestamp_filter or filters.append(event_timestamp_filter) - not args.c42usernames or filters.append(DeviceUsername.is_in(args.c42usernames)) - not args.actors or filters.append(Actor.is_in(args.actors)) - not args.md5_hashes or filters.append(MD5.is_in(args.md5_hashes)) - not args.sha256_hashes or filters.append(SHA256.is_in(args.sha256_hashes)) - not args.sources or filters.append(Source.is_in(args.sources)) - not args.filenames or filters.append(FileName.is_in(args.filenames)) - not args.filepaths or filters.append(FilePath.is_in(args.filepaths)) - not args.process_owners or filters.append(ProcessOwner.is_in(args.process_owners)) - not args.tab_urls or filters.append(TabURL.is_in(args.tab_urls)) - _try_append_exposure_types_filter(filters, args) + not args.c42username or filters.append(DeviceUsername.is_in(args.c42username)) + not args.actor or filters.append(Actor.is_in(args.actor)) + not args.md5 or filters.append(MD5.is_in(args.md5)) + not args.sha256 or filters.append(SHA256.is_in(args.sha256)) + not args.source or filters.append(Source.is_in(args.source)) + not args.filename or filters.append(FileName.is_in(args.filename)) + not args.filepath or filters.append(FilePath.is_in(args.filepath)) + not args.processOwner or filters.append(ProcessOwner.is_in(args.processOwner)) + not args.tabURL or filters.append(TabURL.is_in(args.tabURL)) + _try_append_exposure_types_filter(filters, args.include_non_exposure, args.type) return filters -def _get_event_timestamp_filter(args): +def _get_event_timestamp_filter(begin_date, end_date): try: - begin_date = args.begin_date.strip().split(" ") if args.begin_date else None - end_date = args.end_date.strip().split(" ") if args.end_date else None + begin_date = begin_date.strip().split() if begin_date else None + end_date = end_date.strip().split() if end_date else None return date_helper.create_event_timestamp_filter(begin_date, end_date) except ValueError as ex: print_error(str(ex)) @@ -152,9 +149,9 @@ def handle_response(response): return handlers -def _call_extract(extractor, filters, args): - if args.advanced_query: - extractor.extract_advanced(args.advanced_query) +def _call_extract(extractor, filters, advanced_query): + if advanced_query: + extractor.extract_advanced(advanced_query) else: extractor.extract(*filters) @@ -173,22 +170,21 @@ def _verify_compatibility_with_advanced_query(key, val): def _handle_result(): if is_interactive() and _EXCEPTIONS_OCCURRED: print_error(u"View exceptions that occurred at [HOME]/.code42cli/log/code42_errors.") - global _TOTAL_EVENTS if not _TOTAL_EVENTS: print_to_stderr(u"No results found\n") -def _try_append_exposure_types_filter(filters, args): - exposure_filter = _create_exposure_type_filter(args) +def _try_append_exposure_types_filter(filters, include_non_exposure_events, exposure_types): + exposure_filter = _create_exposure_type_filter(include_non_exposure_events, exposure_types) if exposure_filter: filters.append(exposure_filter) -def _create_exposure_type_filter(args): - if args.include_non_exposure_events and args.exposure_types: +def _create_exposure_type_filter(include_non_exposure_events, exposure_types): + if include_non_exposure_events and exposure_types: print_error(u"Cannot use exposure types with `--include-non-exposure`.") exit(1) - if args.exposure_types: - return ExposureType.is_in(args.exposure_types) - if not args.include_non_exposure_events: + if exposure_types: + return ExposureType.is_in(exposure_types) + if not include_non_exposure_events: return ExposureType.exists() diff --git a/src/code42cli/securitydata/logger_factory.py b/src/code42cli/cmds/securitydata/logger_factory.py similarity index 96% rename from src/code42cli/securitydata/logger_factory.py rename to src/code42cli/cmds/securitydata/logger_factory.py index c114aff7c..3dc2b253b 100644 --- a/src/code42cli/securitydata/logger_factory.py +++ b/src/code42cli/cmds/securitydata/logger_factory.py @@ -1,18 +1,18 @@ -import logging import sys -from logging.handlers import RotatingFileHandler -from threading import Lock +import logging from c42eventextractor.logging.formatters import ( - FileEventDictToJSONFormatter, FileEventDictToCEFFormatter, + FileEventDictToJSONFormatter, FileEventDictToRawJSONFormatter, ) from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper +from logging.handlers import RotatingFileHandler +from threading import Lock +from code42cli.cmds.securitydata.enums import OutputFormat from code42cli.compat import str -from code42cli.securitydata.options import OutputFormat -from code42cli.util import get_user_project_path, print_error, get_url_parts +from code42cli.util import get_url_parts, get_user_project_path, print_error _logger_deps_lock = Lock() diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py new file mode 100644 index 000000000..b5e284ae5 --- /dev/null +++ b/src/code42cli/cmds/securitydata/main.py @@ -0,0 +1,191 @@ +from code42cli.args import ArgConfig +from code42cli.cmds.securitydata import enums, logger_factory +from code42cli.cmds.securitydata.extraction import extract +from code42cli.cmds.shared.cursor_store import FileEventCursorStore +from code42cli.commands import Command + + +def load_subcommands(): + """Sets up the `securitydata` subcommand with all of its subcommands.""" + usage_prefix = u"code42 securitydata" + + print_func = Command( + u"print", + u"Print file events to stdout", + u"{} {}".format(usage_prefix, u"print "), + handler=print_out, + arg_customizer=_load_search_args, + use_single_arg_obj=True, + ) + + write = Command( + u"write-to", + u"Write file events to the file with the given name.", + u"{} {}".format(usage_prefix, u"write-to "), + handler=write_to, + arg_customizer=_load_write_to_args, + use_single_arg_obj=True, + ) + + send = Command( + u"send-to", + u"Send file events to the given server address.", + u"{} {}".format(usage_prefix, u"send-to "), + handler=send_to, + arg_customizer=_load_send_to_args, + use_single_arg_obj=True, + ) + + clear = Command( + u"clear-checkpoint", + u"Remove the saved checkpoint from 'incremental' (-i) mode.", + u"{} {}".format(usage_prefix, u"clear-checkpoint "), + handler=clear_checkpoint, + ) + + return [print_func, write, send, clear] + + +def clear_checkpoint(sdk, profile): + """Removes the stored checkpoint that keeps track of the last event you got. + To use, run `code42 securitydata clear-checkpoint`. + This affects `incremental` mode by causing it to behave like it has never been run before. + """ + FileEventCursorStore(profile.name).replace_stored_insertion_timestamp(None) + + +def print_out(sdk, profile, args): + """Activates 'print' command. It gets security events and prints them to stdout.""" + logger = logger_factory.get_logger_for_stdout(args.format) + extract(sdk, profile, logger, args) + + +def write_to(sdk, profile, args): + """Activates 'write-to' command. It gets security events and writes them to the given file.""" + logger = logger_factory.get_logger_for_file(args.output_file, args.format) + extract(sdk, profile, logger, args) + + +def send_to(sdk, profile, args): + """Activates 'send-to' command. It gets security events and logs them to the given server.""" + logger = logger_factory.get_logger_for_server(args.server, args.protocol, args.format) + extract(sdk, profile, logger, args) + + +def _load_write_to_args(arg_collection): + output_file = ArgConfig(u"output_file", help=u"The name of the local file to send output to.") + arg_collection.add(u"output_file", output_file) + _load_search_args(arg_collection) + + +def _load_send_to_args(arg_collection): + send_to_args = { + u"server": ArgConfig(u"server", help=u"The server address to send output to."), + u"protocol": ArgConfig( + u"-p", + u"--protocol", + choices=enums.ServerProtocol(), + default=enums.ServerProtocol.UDP, + help=u"Protocol used to send logs to server.", + ), + } + + arg_collection.extend(send_to_args) + _load_search_args(arg_collection) + + +def _load_search_args(arg_collection): + search_args = { + enums.SearchArguments.ADVANCED_QUERY: ArgConfig( + u"--advanced-query", + help=u"A raw JSON file event query. " + u"Useful for when the provided query parameters do not satisfy your requirements." + u"WARNING: Using advanced queries ignores all other query parameters.", + ), + enums.SearchArguments.BEGIN_DATE: ArgConfig( + u"-b", + u"--{}".format(enums.SearchArguments.BEGIN_DATE), + help=u"The beginning of the date range in which to look for events, " + u"in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", + ), + enums.SearchArguments.END_DATE: ArgConfig( + u"-e", + u"--{}".format(enums.SearchArguments.END_DATE), + help=u"The end of the date range in which to look for events, " + u"in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", + ), + enums.SearchArguments.EXPOSURE_TYPES: ArgConfig( + u"-t", + u"--{}".format(enums.SearchArguments.EXPOSURE_TYPES), + nargs=u"+", + help=u"Limits events to those with given exposure types. " + u"Available choices={0}".format(list(enums.ExposureType())), + ), + enums.SearchArguments.C42USERNAME: ArgConfig( + u"--{}".format(enums.SearchArguments.C42USERNAME), + nargs=u"+", + help=u"Limits events to endpoint events for these users.", + ), + enums.SearchArguments.ACTOR: ArgConfig( + u"--{}".format(enums.SearchArguments.ACTOR), + nargs=u"+", + help=u"Limits events to only those enacted by the cloud service user of the person who caused the event.", + ), + enums.SearchArguments.MD5: ArgConfig( + u"--{}".format(enums.SearchArguments.MD5), + nargs=u"+", + help=u"Limits events to file events where the file has one of these MD5 hashes.", + ), + enums.SearchArguments.SHA256: ArgConfig( + u"--{}".format(enums.SearchArguments.SHA256), + nargs=u"+", + action=u"store", + help=u"Limits events to file events where the file has one of these SHA256 hashes.", + ), + enums.SearchArguments.SOURCE: ArgConfig( + u"--{}".format(enums.SearchArguments.SOURCE), + nargs=u"+", + help=u"Limits events to only those from one of these sources. Example=Gmail.", + ), + enums.SearchArguments.FILENAME: ArgConfig( + u"--{}".format(enums.SearchArguments.FILENAME), + nargs=u"+", + help=u"Limits events to file events where the file has one of these names.", + ), + enums.SearchArguments.FILEPATH: ArgConfig( + u"--{}".format(enums.SearchArguments.FILEPATH), + nargs=u"+", + help=u"Limits events to file events where the file is located at one of these paths.", + ), + enums.SearchArguments.PROCESS_OWNER: ArgConfig( + u"--{}".format(enums.SearchArguments.PROCESS_OWNER), + nargs=u"+", + help=u"Limits events to exposure events where one of these users " + u"owns the process behind the exposure.", + ), + enums.SearchArguments.TAB_URL: ArgConfig( + u"--{}".format(enums.SearchArguments.TAB_URL), + nargs=u"+", + help=u"Limits events to be exposure events with one of these destination tab URLs.", + ), + enums.SearchArguments.INCLUDE_NON_EXPOSURE_EVENTS: ArgConfig( + u"--include-non-exposure", + action=u"store_true", + help=u"Get all events including non-exposure events.", + ), + u"format": ArgConfig( + u"-f", + u"--format", + choices=enums.OutputFormat(), + default=enums.OutputFormat.JSON, + help=u"The format used for outputting events.", + ), + u"incremental": ArgConfig( + u"-i", + u"--incremental", + action=u"store_true", + help=u"Only get events that were not previously retrieved.", + ), + } + + arg_collection.extend(search_args) diff --git a/src/code42cli/securitydata/__init__.py b/src/code42cli/cmds/shared/__init__.py similarity index 100% rename from src/code42cli/securitydata/__init__.py rename to src/code42cli/cmds/shared/__init__.py diff --git a/src/code42cli/securitydata/cursor_store.py b/src/code42cli/cmds/shared/cursor_store.py similarity index 100% rename from src/code42cli/securitydata/cursor_store.py rename to src/code42cli/cmds/shared/cursor_store.py diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py new file mode 100644 index 000000000..0d9d1dff1 --- /dev/null +++ b/src/code42cli/commands.py @@ -0,0 +1,133 @@ +import inspect + +from code42cli import profile as cliprofile +from code42cli.args import get_auto_arg_configs +from code42cli.sdk_client import create_sdk + + +class DictObject(object): + def __init__(self, _dict): + self.__dict__ = _dict + + +class Command(object): + """Represents a function that a CLI user can execute. Add a command to + `code42cli.main._load_top_commands` or as a subcommand of one those + commands to make it available for use. + + Args: + name (str): The name of the command. For example, in + `code42 profile show`, "show" is the name, while "profile" + is the name of the parent command. + + description (str): Descriptive text to be displayed when using -h. + + usage (str, optional): A usage example to be displayed when using -h. + handler (function, optional): The function to be exectued when the command is run. + + arg_customizer (function, optional): A function accepting a single `ArgCollection` + parameter that allows for editing the collection when `get_arg_configs` is run. + + subcommand_loader (function, optional): A function returning a list of all subcommands + parented by this command. + + use_single_arg_obj (bool, optional): When True, causes all parameters sent to + `__call__` to be consolidated in an object with attribute names dictated + by the parameter names. That object is passed to `handler`'s `arg` parameter. + """ + + def __init__( + self, + name, + description, + usage=None, + handler=None, + arg_customizer=None, + subcommand_loader=None, + use_single_arg_obj=None, + ): + + self._name = name + self._description = description + self._usage = usage + self._handler = handler + self._arg_customizer = arg_customizer + self._subcommand_loader = subcommand_loader + self._use_single_arg_obj = use_single_arg_obj + self._subcommands = [] + + def __call__(self, *args, **kwargs): + """Passes the parsed argparse args to the handler, or + shows the help of for this command if there is no handler + (common in commands that are simply groups of subcommands). + """ + if callable(self._handler): + kvps = _get_arg_kvps(args[0], self._handler) + if self._use_single_arg_obj: + kvps = _kvps_to_obj(kvps) + return self._handler(**kvps) + help_func = kwargs.pop(u"help_func", None) + if help_func: + return help_func() + + @property + def name(self): + return self._name + + @property + def description(self): + return self._description + + @property + def usage(self): + return self._usage + + @property + def subcommands(self): + return self._subcommands + + def load_subcommands(self): + if callable(self._subcommand_loader): + self._subcommands = self._subcommand_loader() + + def get_arg_configs(self): + """Returns a collection of argparse configurations based on + the parameter names of `handler` and any user customizations.""" + arg_config_collection = get_auto_arg_configs(self._handler) + if callable(self._arg_customizer): + self._arg_customizer(arg_config_collection) + + return arg_config_collection.arg_configs + + +def _get_arg_kvps(parsed_args, handler): + # transform parsed args from argparse into a dict + kvps = dict(vars(parsed_args)) + kvps.pop(u"func", None) + return _inject_params(kvps, handler) + + +def _inject_params(kvps, handler): + """automatically populates parameters named "sdk" or "profile" with instances of the sdk + and profile, respectively.""" + if _handler_has_arg(u"sdk", handler): + profile_name = kvps.pop(u"profile", None) + debug = kvps.pop(u"debug", None) + + profile = cliprofile.get_profile(profile_name) + kvps[u"sdk"] = create_sdk(profile, debug) + + if _handler_has_arg(u"profile", handler): + kvps[u"profile"] = profile + return kvps + + +def _handler_has_arg(arg_name, handler): + argspec = inspect.getargspec(handler) + return arg_name in argspec.args + + +def _kvps_to_obj(kvps): + new_kvps = {key: kvps[key] for key in kvps if key in [u"sdk", u"profile"]} + new_kvps[u"args"] = DictObject(kvps) + return new_kvps diff --git a/src/code42cli/profile/config.py b/src/code42cli/config.py similarity index 73% rename from src/code42cli/profile/config.py rename to src/code42cli/config.py index b2359595a..61cbb76a8 100644 --- a/src/code42cli/profile/config.py +++ b/src/code42cli/config.py @@ -43,7 +43,7 @@ def get_all_profiles(self): profiles.append(self.get_profile(name)) return profiles - def create_profile_if_not_exists(self, name): + def create_profile(self, name, server, username, ignore_ssl_errors): """Creates a new profile if one does not already exist for that name.""" try: self.get_profile(name) @@ -52,6 +52,11 @@ def create_profile_if_not_exists(self, name): self._create_profile_section(name) else: raise ex + profile = self.parser[name] + self._set_authority_url(server, profile) + self._set_username(username, profile) + self._set_ignore_ssl_errors(ignore_ssl_errors, profile) + self._try_complete_setup(profile) def switch_default_profile(self, new_default_name): """Changes what is marked as the default profile in the internal section.""" @@ -59,34 +64,19 @@ def switch_default_profile(self, new_default_name): raise Exception(u"Profile does not exist.") self._internal[self.DEFAULT_PROFILE] = new_default_name self._save() + print(u"{} has been set as the default profile.".format(new_default_name)) - def set_authority_url(self, new_value, profile_name=None): - """Sets 'authority URL' for a given profile. - Uses the default profile if name is None. - """ - profile = self.get_profile(profile_name) + def _set_authority_url(self, new_value, profile): profile[self.AUTHORITY_KEY] = new_value.strip() - self._save() - self._try_complete_setup(profile) - def set_username(self, new_value, profile_name=None): - """Sets 'username' for a given profile. Uses the default profile if not given a name.""" - profile = self.get_profile(profile_name) + def _set_username(self, new_value, profile): profile[self.USERNAME_KEY] = new_value.strip() - self._save() - self._try_complete_setup(profile) - def set_ignore_ssl_errors(self, new_value, profile_name=None): - """Sets 'ignore_ssl_errors' for a given profile. - Uses the default profile if name is None. - """ - profile = self.get_profile(profile_name) + def _set_ignore_ssl_errors(self, new_value, profile): profile[self.IGNORE_SSL_ERRORS_KEY] = str(new_value) - self._save() @property def _internal(self): - """The internal section of the config file.""" return self.parser[self._INTERNAL_SECTION] @property @@ -110,17 +100,11 @@ def _create_profile_section(self, name): self.parser[name][self.AUTHORITY_KEY] = self.DEFAULT_VALUE self.parser[name][self.USERNAME_KEY] = self.DEFAULT_VALUE self.parser[name][self.IGNORE_SSL_ERRORS_KEY] = str(False) - default_profile = self._internal.get(self.DEFAULT_PROFILE) - if default_profile is None or default_profile is self.DEFAULT_VALUE: - self._internal[self.DEFAULT_PROFILE] = name def _save(self): util.open_file(self.path, u"w+", lambda file: self.parser.write(file)) def _try_complete_setup(self, profile): - if self._internal.getboolean(self.DEFAULT_PROFILE_IS_COMPLETE): - return - authority = profile.get(self.AUTHORITY_KEY) username = profile.get(self.USERNAME_KEY) @@ -130,13 +114,12 @@ def _try_complete_setup(self, profile): if not authority_valid or not username_valid: return - self._internal[self.DEFAULT_PROFILE_IS_COMPLETE] = str(True) - if self._internal[self.DEFAULT_PROFILE] == self.DEFAULT_VALUE: - self._internal[self.DEFAULT_PROFILE] = profile.name - self._save() + print(u"Successfully saved profile '{}'.".format(profile.name)) + + default_profile = self._internal.get(self.DEFAULT_PROFILE) + if default_profile is None or default_profile == self.DEFAULT_VALUE: + self.switch_default_profile(profile.name) -def get_config_accessor(): - """Create a ConfigAccessor with a ConfigParser as its parser.""" - return ConfigAccessor(ConfigParser()) +config_accessor = ConfigAccessor(ConfigParser()) diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py new file mode 100644 index 000000000..9635813f3 --- /dev/null +++ b/src/code42cli/invoker.py @@ -0,0 +1,69 @@ +from __future__ import print_function + +import sys + +from code42cli.parser import ArgumentParserError, CommandParser + + +class CommandInvoker(object): + def __init__(self, top_command, cmd_parser=None): + self._top_command = top_command + self._cmd_parser = cmd_parser or CommandParser() + self._commands = {} + self._commands[u""] = self._top_command + + def run(self, input_args): + """Locates a command that matches the one specified by + `input_args` and runs it with the supplied parameters. + + Args: + input_args (iter[str]): the full list of arguments + supplied by the user to `code42` cli command. + """ + path_parts = self._get_path_parts(input_args) + command = self._commands.get(" ".join(path_parts)) + self._try_run_command(command, path_parts, input_args) + + def _get_path_parts(self, input_args): + """Gets the portion of `input_args` that refers to a + valid command or subcommand, removing parameters. + For example, `input_args` of ["command", "sub", "--arg", "argval"] + would return ["command", "sub"], assuming "command" is a top level command, + "sub" is a subcommand of "command", and the rest of the values are normal parameters. + Returns an empty string if a valid command or subcommand is not found. + """ + path = u"" + node = self._commands[u""] + # step through each segment of input_args until we find + # something that _isn't_ a command or subcommand. + for arg in input_args: + new_path = u"{} {}".format(path, arg).strip() + self._load_subcommands(path, node) + node = self._commands.get(new_path) + if not node: + break + path = new_path + return path.split() + + def _load_subcommands(self, path, node): + """Discovers a command's subcommands and registers them + to the available list of commands for this Invoker.""" + node.load_subcommands() + for command in node.subcommands: + new_key = u"{} {}".format(path, command.name).strip() + self._commands[new_key] = command + + def _try_run_command(self, command, path_parts, input_args): + """Runs a command called using `path_parts` by parsing + `input_args` and calling the command's handler.""" + try: + if not path_parts: + parser = self._cmd_parser.prepare_cli_help(command) + else: + parser = self._cmd_parser.prepare_command(command, path_parts) + parsed_args = self._cmd_parser.parse_args(input_args) + parsed_args.func(parsed_args) + except ArgumentParserError as e: + print(u"error: {}".format(e), file=sys.stderr) + parser.print_help(sys.stderr) + sys.exit(2) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index f5725a370..f91cfeb10 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,9 +1,11 @@ +import sys + import platform -from argparse import ArgumentParser, RawDescriptionHelpFormatter -import code42cli.securitydata.main as securitydata -from code42cli.compat import str -from code42cli.profile import profile +from code42cli.cmds import profile +from code42cli.cmds.securitydata import main as secmain +from code42cli.commands import Command +from code42cli.invoker import CommandInvoker # If on Windows, configure console session to handle ANSI escape sequences correctly # source: https://bugs.python.org/issue29059 @@ -18,31 +20,22 @@ def main(): - description = u""" - Groups: - profile - For managing Code42 settings. - securitydata - Tools for getting security related data, such as file events. - """ - code42_arg_parser = ArgumentParser( - formatter_class=RawDescriptionHelpFormatter, - description=description, - usage=u"code42 ", - ) - subcommand_parser = code42_arg_parser.add_subparsers(title=u"groups") - profile.init(subcommand_parser) - securitydata.init_subcommand(subcommand_parser) - _run(code42_arg_parser) - - -def _run(parser): - try: - args = parser.parse_args() - args.func(args) - except AttributeError as ex: - if str(ex) == u"'Namespace' object has no attribute 'func'": - parser.print_help() - return - raise ex + top = Command("", "", subcommand_loader=_load_top_commands) + invoker = CommandInvoker(top) + invoker.run(sys.argv[1:]) + + +def _load_top_commands(): + return [ + Command( + u"profile", u"For managing Code42 settings.", subcommand_loader=profile.load_subcommands + ), + Command( + u"securitydata", + u"Tools for getting security related data, such as file events.", + subcommand_loader=secmain.load_subcommands, + ), + ] if __name__ == "__main__": diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py new file mode 100644 index 000000000..5e458ad26 --- /dev/null +++ b/src/code42cli/parser.py @@ -0,0 +1,108 @@ +import argparse +from argparse import RawDescriptionHelpFormatter, SUPPRESS +from py42.__version__ import __version__ as py42version + +from code42cli.__version__ import __version__ as cliversion + +BANNER = u""" + dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. +dP `" dP Yb 8I Yb 88__ dP 88 "' dP' +Yb Yb dP 8I dY 88"" d888888 dP' + YboodP YbodP 8888Y" 888888 88 .d8888 + +code42cli version {}, by Code42 Software. +powered by py42 version {}.""".format( + cliversion, py42version +) + + +class ArgumentParserError(Exception): + pass + + +class CommandParser(argparse.ArgumentParser): + def __init__(self, **kwargs): + super(CommandParser, self).__init__(formatter_class=RawDescriptionHelpFormatter, **kwargs) + + def prepare_command(self, command, path_parts): + parser = self._get_parser(command, path_parts) + self._load_argparse_config(command, parser) + parser.set_defaults(func=lambda args: command(args, help_func=parser.print_help)) + return parser + + def prepare_cli_help(self, top_command): + top_command.load_subcommands() + self.description = _get_group_help(top_command) + self.usage = SUPPRESS + self.set_defaults(func=lambda _: self.print_help()) + return self + + def error(self, message): + # overrides the behavior of when an error occurs when + # arguments cant be successfully parsed. CommandInvoker catches this. + raise ArgumentParserError(message) + + def _load_argparse_config(self, command, command_parser): + arg_configs = command.get_arg_configs() + for arg in arg_configs: + _add_argument(command_parser, arg_configs[arg].settings) + + def _get_parser(self, command, path_parts): + usage = command.usage or SUPPRESS + command.load_subcommands() + description = _get_group_help(command) if command.subcommands else command.description + subparser = self._get_subparser(path_parts) + return subparser.add_parser(command.name, description=description, usage=usage) + + def _get_subparser(self, path_parts): + global_subparser = self.add_subparsers() + global_subparser.required = True + subparsers = {(): global_subparser} + parent_subparser = global_subparser + + # build out the entire path of subparsers up to the command + for part in range(0, len(path_parts)): + parent_path_parts = tuple(path_parts[:part]) + parent_subparser = subparsers.get(parent_path_parts) + if not parent_subparser: + parent_subparser = _get_parent_subparser(path_parts, part, subparsers) + subparsers[parent_path_parts] = parent_subparser + return parent_subparser + + +def _get_parent_subparser(path_parts, part, subparsers): + grandparent_path_parts = tuple(path_parts[: part - 1]) + grandparent_subparser = subparsers[grandparent_path_parts] + + new_path = path_parts[part - 1] + new_parser = grandparent_subparser.add_parser(new_path) + parent_subparser = new_parser.add_subparsers() + parent_subparser.required = True + + return parent_subparser + + +def _add_argument(parser, arg_settings): + # register the settings of an ArgConfig object to an argparse parser + options_list = arg_settings.pop(u"options_list") + arg_settings = {key: arg_settings[key] for key in arg_settings if arg_settings[key] is not None} + parser.add_argument(*options_list, **arg_settings) + + +def _get_group_help(command): + descriptions = _build_group_command_descriptions(command) + output = [] + if not command.name: + name = u"code42" + output.append(BANNER) + + output.extend([u" \nAvailable commands in <{}>:".format(command.name), descriptions]) + return "\n".join(output) + + +def _build_group_command_descriptions(command): + subs = command.subcommands + name = command.name + name_width = len(max([cmd.name for cmd in subs], key=len)) + lines = [u" {} - {}".format(cmd.name.ljust(name_width), cmd.description) for cmd in subs] + return u"\n".join(lines) diff --git a/src/code42cli/password.py b/src/code42cli/password.py new file mode 100644 index 000000000..7c3e43996 --- /dev/null +++ b/src/code42cli/password.py @@ -0,0 +1,38 @@ +from __future__ import print_function + +import keyring +from getpass import getpass + +from code42cli.util import does_user_agree + +_ROOT_SERVICE_NAME = u"code42cli" + + +def get_stored_password(profile): + """Gets your currently stored password for the given profile name.""" + service_name = _get_keyring_service_name(profile.name) + return keyring.get_password(service_name, profile.username) + + +def get_password_from_prompt(): + """Prompts you and returns what you input.""" + return getpass() + + +def set_password(profile, new_password): + """Sets your password for the given profile name.""" + service_name = _get_keyring_service_name(profile.name) + uses_file_storage = keyring.get_keyring().priority < 1 + if uses_file_storage and not _prompt_for_alternative_store(): + return + + keyring.set_password(service_name, profile.username, new_password) + + +def _get_keyring_service_name(profile_name): + return u"{}::{}".format(_ROOT_SERVICE_NAME, profile_name) + + +def _prompt_for_alternative_store(): + prompt = u"keyring is unavailable. Would you like to store in secure flat file? (y/n): " + return does_user_agree(prompt) diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py new file mode 100644 index 000000000..b11fef994 --- /dev/null +++ b/src/code42cli/profile.py @@ -0,0 +1,88 @@ +import code42cli.password as password +from code42cli.config import ConfigAccessor, config_accessor +from code42cli.util import print_error, print_create_profile_help + + +class Code42Profile(object): + def __init__(self, profile): + self._profile = profile + + @property + def name(self): + return self._profile.name + + @property + def authority_url(self): + return self._profile[ConfigAccessor.AUTHORITY_KEY] + + @property + def username(self): + return self._profile[ConfigAccessor.USERNAME_KEY] + + @property + def ignore_ssl_errors(self): + return self._profile[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] + + def get_password(self): + pwd = password.get_stored_password(self) + if not pwd: + pwd = password.get_password_from_prompt() + return pwd + + def __str__(self): + return u"{0}: Username={1}, Authority URL={2}".format( + self.name, self.username, self.authority_url + ) + + +def _get_profile(profile_name=None): + """Returns the profile for the given name.""" + return Code42Profile(config_accessor.get_profile(profile_name)) + + +def get_profile(profile_name=None): + try: + return _get_profile(profile_name) + except Exception as ex: + print_error(str(ex)) + print_create_profile_help() + exit(1) + + +def default_profile_exists(): + try: + profile = _get_profile() + return profile.name and profile.name != ConfigAccessor.DEFAULT_VALUE + except Exception: + return False + + +def profile_exists(profile_name=None): + try: + _get_profile(profile_name) + return True + except Exception: + return False + + +def switch_default_profile(profile_name): + config_accessor.switch_default_profile(profile_name) + + +def create_profile(name, server, username, ignore_ssl_errors): + config_accessor.create_profile(name, server, username, ignore_ssl_errors) + + +def get_all_profiles(): + profiles = [Code42Profile(profile) for profile in config_accessor.get_all_profiles()] + return profiles + + +def get_stored_password(profile_name=None): + profile = get_profile(profile_name) + return password.get_stored_password(profile) + + +def set_password(new_password, profile_name=None): + profile = get_profile(profile_name) + password.set_password(profile, new_password) diff --git a/src/code42cli/profile/password.py b/src/code42cli/profile/password.py deleted file mode 100644 index 8e3c23d46..000000000 --- a/src/code42cli/profile/password.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import print_function - -import os -import stat -from getpass import getpass - -import keyring - -from code42cli.profile.config import get_config_accessor, ConfigAccessor -from code42cli.util import does_user_agree, open_file, get_user_project_path, print_error - -_ROOT_SERVICE_NAME = u"code42cli" - - -def get_stored_password(profile_name): - """Gets your currently stored password for the given profile name.""" - profile = _get_profile(profile_name) - return _get_stored_password(profile) - - -def get_password_from_prompt(): - """Prompts you and returns what you input.""" - return getpass() - - -def set_password(profile_name, new_password): - """Sets your password for the given profile name.""" - profile = _get_profile(profile_name) - service_name = _get_keyring_service_name(profile.name) - username = _get_username(profile) - if _store_password(profile, service_name, username, new_password): - print(u"'Code42 Password' updated.") - - -def _get_profile(profile_name): - accessor = get_config_accessor() - return accessor.get_profile(profile_name) - - -def _get_stored_password(profile): - password = _get_password_from_keyring(profile) or _get_password_from_file(profile) - return password - - -def _get_keyring_service_name(profile_name): - return u"{}::{}".format(_ROOT_SERVICE_NAME, profile_name) - - -def _get_password_from_keyring(profile): - try: - service_name = _get_keyring_service_name(profile.name) - username = _get_username(profile) - return keyring.get_password(service_name, username) - except: - return None - - -def _get_password_from_file(profile): - path = _get_password_file_path(profile) - - def read_password(file): - try: - return file.readline().strip() - except Exception: - return None - - try: - return open_file(path, u"r", lambda file: read_password(file)) - except Exception: - return None - - -def _store_password(profile, service_name, username, new_password): - return _store_password_using_keyring( - service_name, username, new_password - ) or _store_password_using_file(profile, new_password) - - -def _store_password_using_keyring(service_name, username, new_password): - try: - keyring.set_password(service_name, username, new_password) - was_successful = keyring.get_password(service_name, username) is not None - return was_successful - except: - return False - - -def _store_password_using_file(profile, new_password): - save_to_file = _prompt_for_alternative_store() - if save_to_file: - path = _get_password_file_path(profile) - - def write_password(file): - try: - file.truncate(0) - line = u"{0}\n".format(new_password) - file.write(line) - os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) - return True - except Exception as ex: - print_error(str(ex)) - return False - - return open_file(path, u"w+", lambda file: write_password(file)) - return False - - -def _get_password_file_path(profile): - project_path = get_user_project_path() - return u"{0}.{1}".format(project_path, profile.name.lower()) - - -def _get_username(profile): - return profile[ConfigAccessor.USERNAME_KEY] - - -def _prompt_for_alternative_store(): - prompt = u"keyring is unavailable. Would you like to store in secure flat file? (y/n): " - return does_user_agree(prompt) diff --git a/src/code42cli/profile/profile.py b/src/code42cli/profile/profile.py deleted file mode 100644 index a3e4caadc..000000000 --- a/src/code42cli/profile/profile.py +++ /dev/null @@ -1,302 +0,0 @@ -from __future__ import print_function - -from argparse import RawDescriptionHelpFormatter - -import code42cli.arguments as main_args -import code42cli.profile.password as password -from code42cli.compat import str -from code42cli.profile.config import get_config_accessor, ConfigAccessor -from code42cli.sdk_client import validate_connection -from code42cli.util import ( - does_user_agree, - print_error, - print_set_profile_help, - print_no_existing_profile_message, -) - - -class Code42Profile(object): - def __init__(self, profile): - self._profile = profile - - @property - def name(self): - return self._profile.name - - @property - def authority_url(self): - return self._profile[ConfigAccessor.AUTHORITY_KEY] - - @property - def username(self): - return self._profile[ConfigAccessor.USERNAME_KEY] - - @property - def ignore_ssl_error(self): - return self._profile[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] - - def get_password(self): - pwd = password.get_stored_password(self.name) - if not pwd: - pwd = password.get_password_from_prompt() - return pwd - - def __str__(self): - return u"{0}: Username={1}, Authority URL={2}".format( - self.name, self.username, self.authority_url - ) - - -def init(subcommand_parser): - """Sets up the `profile` subcommand with `show` and `set` subcommands. - `show` will print the current profile while `set` will modify profile properties. - Use `-h` after any subcommand for usage. - Args: - subcommand_parser: The subparsers group created by the parent parser. - """ - - description = u""" - Subcommands: - show - Print the details of a profile. - set - Create or update profile settings. The first profile created will be the default. - reset-pw - Change the stored password for a profile. - list - Show all existing stored profiles. - use - Set a profile as the default. - """ - parser_profile = subcommand_parser.add_parser( - u"profile", - formatter_class=RawDescriptionHelpFormatter, - description=description, - usage=u"code42 profile ", - ) - profile_subparsers = parser_profile.add_subparsers(title="subcommands") - - parser_for_show = profile_subparsers.add_parser( - u"show", - description=u"Print the details of a profile.", - usage=u"code42 profile show ", - ) - parser_for_set = profile_subparsers.add_parser( - u"set", - description=u"Create or update profile settings. The first profile created will be the default.", - usage=u"code42 profile set ", - ) - parser_for_reset_password = profile_subparsers.add_parser( - u"reset-pw", - description=u"Change the stored password for a profile.", - usage=u"code42 profile reset-pw ", - ) - parser_for_list = profile_subparsers.add_parser( - u"list", - description=u"Show all existing stored profiles.", - usage=u"code42 profile list ", - ) - parser_for_use = profile_subparsers.add_parser( - u"use", - description=u"Set a profile as the default.", - usage=u"code42 profile use ", - ) - - parser_for_show.set_defaults(func=show_profile) - parser_for_set.set_defaults(func=set_profile) - parser_for_reset_password.set_defaults(func=prompt_for_password_reset) - parser_for_list.set_defaults(func=list_profiles) - parser_for_use.set_defaults(func=use_profile) - - main_args.add_profile_name_arg(parser_for_show) - main_args.add_profile_name_arg(parser_for_reset_password) - _add_args_to_set_command(parser_for_set) - _add_positional_profile_arg(parser_for_use) - - -def get_profile(profile_name=None): - """Returns the profile for the given name.""" - accessor = get_config_accessor() - try: - profile = accessor.get_profile(profile_name) - return Code42Profile(profile) - except Exception as ex: - print_error(str(ex)) - print_set_profile_help() - exit(1) - - -def show_profile(args): - """Prints the given profile to stdout.""" - profile = get_profile(args.profile_name) - print(u"\n{0}:".format(profile.name)) - print(u"\t* {0} = {1}".format(ConfigAccessor.USERNAME_KEY, profile.username)) - print(u"\t* {0} = {1}".format(ConfigAccessor.AUTHORITY_KEY, profile.authority_url)) - print(u"\t* {0} = {1}".format(ConfigAccessor.IGNORE_SSL_ERRORS_KEY, profile.ignore_ssl_error)) - if password.get_stored_password(profile.name) is not None: - print(u"\t* A password is set.") - print(u"") - - -def set_profile(args): - """Sets the given profile using command line arguments.""" - _verify_args_for_set(args) - accessor = get_config_accessor() - accessor.create_profile_if_not_exists(args.profile_name) - _try_set_authority_url(args, accessor) - _try_set_username(args, accessor) - _try_set_ignore_ssl_errors(args, accessor) - _prompt_for_allow_password_set(args) - - -def prompt_for_password_reset(args): - """Securely prompts for your password and then stores it using keyring.""" - profile = get_profile(args.profile_name) - new_password = password.get_password_from_prompt() - - if not validate_connection(profile.authority_url, profile.username, new_password): - print_error( - "Your password was not saved because your credentials failed to validate. " - "Check your network connection and the spelling of your username and server URL." - ) - exit(1) - password.set_password(profile.name, new_password) - - -def list_profiles(*args): - """Lists all profiles that exist for this OS user.""" - accessor = get_config_accessor() - profiles = accessor.get_all_profiles() - if not profiles: - print_no_existing_profile_message() - return - for profile in profiles: - profile = Code42Profile(profile) - print(profile) - - -def use_profile(args): - """Changes the default profile to the given one.""" - accessor = get_config_accessor() - try: - accessor.switch_default_profile(args.profile_name) - except Exception as ex: - print_error(str(ex)) - exit(1) - - -def _add_args_to_set_command(parser_for_set): - main_args.add_profile_name_arg(parser_for_set) - _add_authority_arg(parser_for_set) - _add_username_arg(parser_for_set) - _add_disable_ssl_errors_arg(parser_for_set) - _add_enable_ssl_errors_arg(parser_for_set) - - -def _add_positional_profile_arg(parser): - parser.add_argument( - action=u"store", dest=main_args.PROFILE_NAME_KEY, help=main_args.PROFILE_HELP_MESSAGE - ) - - -def _add_authority_arg(parser): - parser.add_argument( - u"-s", - u"--server", - action=u"store", - dest=ConfigAccessor.AUTHORITY_KEY, - help=u"The full scheme, url and port of the Code42 server.", - ) - - -def _add_username_arg(parser): - parser.add_argument( - u"-u", - u"--username", - action=u"store", - dest=ConfigAccessor.USERNAME_KEY, - help=u"The username of the Code42 API user.", - ) - - -def _add_disable_ssl_errors_arg(parser): - parser.add_argument( - u"--disable-ssl-errors", - action=u"store_true", - default=None, - dest=u"disable_ssl_errors", - help=u"For development purposes, do not validate the SSL certificates of Code42 servers." - u"This is not recommended unless it is required.", - ) - - -def _add_enable_ssl_errors_arg(parser): - parser.add_argument( - u"--enable-ssl-errors", - action=u"store_true", - default=None, - dest=u"enable_ssl_errors", - help=u"Do validate the SSL certificates of Code42 servers.", - ) - - -def _verify_args_for_set(args): - if _missing_default_profile(args): - print_error(u"Must supply a name when setting your profile for the first time.") - print_set_profile_help() - exit(1) - - missing_values = not args.c42_username and not args.c42_authority_url - if missing_values: - try: - accessor = get_config_accessor() - profile = Code42Profile(accessor.get_profile(args.profile_name)) - missing_values = not profile.username and not profile.authority_url - except Exception: - missing_values = True - - if missing_values: - print_error(u"Missing username and authority url.") - print_set_profile_help() - exit(1) - - -def _try_set_authority_url(args, accessor): - if args.c42_authority_url is not None: - accessor.set_authority_url(args.c42_authority_url, args.profile_name) - - -def _try_set_username(args, accessor): - if args.c42_username is not None: - accessor.set_username(args.c42_username, args.profile_name) - - -def _try_set_ignore_ssl_errors(args, accessor): - if args.disable_ssl_errors is not None and not args.enable_ssl_errors: - accessor.set_ignore_ssl_errors(True, args.profile_name) - - if args.enable_ssl_errors is not None: - accessor.set_ignore_ssl_errors(False, args.profile_name) - - -def _missing_default_profile(args): - profile_name_arg_is_none = ( - args.profile_name is None or args.profile_name == ConfigAccessor.DEFAULT_VALUE - ) - return profile_name_arg_is_none and not _default_profile_exist() - - -def _default_profile_exist(): - try: - accessor = get_config_accessor() - profile = Code42Profile(accessor.get_profile()) - return profile.name and profile.name != ConfigAccessor.DEFAULT_VALUE - except Exception: - return False - - -def _prompt_for_allow_password_set(args): - if does_user_agree(u"Would you like to set a password? (y/n): "): - prompt_for_password_reset(args) - - -def _log_key_save(key): - if key == ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE: - print(u"You have completed setting up your profile!") - else: - print(u"'{}' has been successfully updated".format(key)) diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index 61ca9fcbe..d54b30ea3 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -23,5 +23,4 @@ def validate_connection(authority_url, username, password): py42.sdk.from_local_account(authority_url, username, password) return True except: - print(username, password, authority_url) return False diff --git a/src/code42cli/securitydata/arguments/main.py b/src/code42cli/securitydata/arguments/main.py deleted file mode 100644 index f3fa0d0f7..000000000 --- a/src/code42cli/securitydata/arguments/main.py +++ /dev/null @@ -1,31 +0,0 @@ -from code42cli.securitydata.options import OutputFormat - - -IS_INCREMENTAL_KEY = u"is_incremental" - - -def add_arguments_to_parser(parser): - _add_output_format_arg(parser) - _add_incremental_arg(parser) - - -def _add_output_format_arg(parser): - parser.add_argument( - u"-f", - u"--format", - dest=u"format", - action=u"store", - choices=OutputFormat(), - default=OutputFormat.JSON, - help=u"The format used for outputting events.", - ) - - -def _add_incremental_arg(parser): - parser.add_argument( - u"-i", - u"--incremental", - dest=IS_INCREMENTAL_KEY, - action=u"store_true", - help=u"Only get events that were not previously retrieved.", - ) diff --git a/src/code42cli/securitydata/arguments/search.py b/src/code42cli/securitydata/arguments/search.py deleted file mode 100644 index c4f9b5cb6..000000000 --- a/src/code42cli/securitydata/arguments/search.py +++ /dev/null @@ -1,200 +0,0 @@ -from code42cli.securitydata.options import ExposureType - - -def add_arguments_to_parser(parser): - _add_advanced_query(parser) - _add_begin_date_arg(parser) - _add_end_date_arg(parser) - _add_exposure_types_arg(parser) - _add_username_arg(parser) - _add_actor_arg(parser) - _add_md5_arg(parser) - _add_sha256_arg(parser) - _add_source_arg(parser) - _add_filename_arg(parser) - _add_filepath_arg(parser) - _add_process_owner_arg(parser) - _add_tab_url_arg(parser) - _add_include_non_exposure_arg(parser) - - -class SearchArguments(object): - ADVANCED_QUERY = u"advanced_query" - BEGIN_DATE = u"begin_date" - END_DATE = u"end_date" - EXPOSURE_TYPES = u"exposure_types" - C42USERNAME = u"c42usernames" - ACTOR = u"actors" - MD5 = u"md5_hashes" - SHA256 = u"sha256_hashes" - SOURCE = u"sources" - FILENAME = u"filenames" - FILEPATH = u"filepaths" - PROCESS_OWNER = u"process_owners" - TAB_URL = u"tab_urls" - INCLUDE_NON_EXPOSURE_EVENTS = u"include_non_exposure_events" - - def __iter__(self): - return iter( - [ - self.ADVANCED_QUERY, - self.BEGIN_DATE, - self.END_DATE, - self.EXPOSURE_TYPES, - self.C42USERNAME, - self.ACTOR, - self.MD5, - self.SHA256, - self.SOURCE, - self.FILENAME, - self.FILEPATH, - self.PROCESS_OWNER, - self.TAB_URL, - self.INCLUDE_NON_EXPOSURE_EVENTS, - ] - ) - - -def _add_advanced_query(parser): - parser.add_argument( - u"--advanced-query", - action=u"store", - dest=SearchArguments.ADVANCED_QUERY, - help=u"A raw JSON file event query. " - u"Useful for when the provided query parameters do not satisfy your requirements." - u"WARNING: Using advanced queries ignores all other query parameters.", - ) - - -def _add_begin_date_arg(parser): - parser.add_argument( - u"-b", - u"--begin", - action=u"store", - dest=SearchArguments.BEGIN_DATE, - help=u"The beginning of the date range in which to look for events, " - u"in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", - ) - - -def _add_end_date_arg(parser): - parser.add_argument( - u"-e", - u"--end", - action=u"store", - dest=SearchArguments.END_DATE, - help=u"The end of the date range in which to look for events, " - u"in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", - ) - - -def _add_exposure_types_arg(parser): - parser.add_argument( - u"-t", - u"--types", - nargs=u"+", - action=u"store", - dest=SearchArguments.EXPOSURE_TYPES, - help=u"Limits events to those with given exposure types. " - u"Available choices={0}".format(list(ExposureType())), - ) - - -def _add_username_arg(parser): - parser.add_argument( - u"--c42username", - nargs=u"+", - action=u"store", - dest=SearchArguments.C42USERNAME, - help=u"Limits events to endpoint events for these users.", - ) - - -def _add_actor_arg(parser): - parser.add_argument( - u"--actor", - nargs=u"+", - action=u"store", - dest=SearchArguments.ACTOR, - help=u"Limits events to only those enacted by the cloud service user of the person who caused the event.", - ) - - -def _add_md5_arg(parser): - parser.add_argument( - u"--md5", - nargs=u"+", - action=u"store", - dest=SearchArguments.MD5, - help=u"Limits events to file events where the file has one of these MD5 hashes.", - ) - - -def _add_sha256_arg(parser): - parser.add_argument( - u"--sha256", - nargs=u"+", - action=u"store", - dest=SearchArguments.SHA256, - help=u"Limits events to file events where the file has one of these SHA256 hashes.", - ) - - -def _add_source_arg(parser): - parser.add_argument( - u"--source", - nargs=u"+", - action=u"store", - dest=SearchArguments.SOURCE, - help=u"Limits events to only those from one of these sources. Example=Gmail.", - ) - - -def _add_filename_arg(parser): - parser.add_argument( - u"--filename", - nargs=u"+", - action=u"store", - dest=SearchArguments.FILENAME, - help=u"Limits events to file events where the file has one of these names.", - ) - - -def _add_filepath_arg(parser): - parser.add_argument( - u"--filepath", - nargs=u"+", - action=u"store", - dest=SearchArguments.FILEPATH, - help=u"Limits events to file events where the file is located at one of these paths.", - ) - - -def _add_process_owner_arg(parser): - parser.add_argument( - u"--processOwner", - nargs=u"+", - action=u"store", - dest=SearchArguments.PROCESS_OWNER, - help=u"Limits events to exposure events where one of these users " - u"owns the process behind the exposure.", - ) - - -def _add_tab_url_arg(parser): - parser.add_argument( - u"--tabURL", - nargs=u"+", - action=u"store", - dest=SearchArguments.TAB_URL, - help=u"Limits events to be exposure events with one of these destination tab URLs.", - ) - - -def _add_include_non_exposure_arg(parser): - parser.add_argument( - u"--include-non-exposure", - action=u"store_true", - dest=SearchArguments.INCLUDE_NON_EXPOSURE_EVENTS, - help=u"Get all events including non-exposure events.", - ) diff --git a/src/code42cli/securitydata/main.py b/src/code42cli/securitydata/main.py deleted file mode 100644 index 166f47cd8..000000000 --- a/src/code42cli/securitydata/main.py +++ /dev/null @@ -1,26 +0,0 @@ -from argparse import RawDescriptionHelpFormatter - -from code42cli.securitydata.subcommands import clear_checkpoint, print_out, write_to -from code42cli.securitydata.subcommands import send_to - - -def init_subcommand(subcommand_parser): - description = u""" - Subcommands: - print - Print file events to stdout. - send-to - Send file events to the given server address. - write-to - Write file events to the file with the given name. - clear-checkpoint - Remove the saved checkpoint from 'incremental' (-i) mode. - """ - securitydata_arg_parser = subcommand_parser.add_parser( - u"securitydata", - formatter_class=RawDescriptionHelpFormatter, - description=description, - epilog=u"Use '--profile ' to execute any of these commands for the given profile.", - usage=u"code42 securitydata ", - ) - securitydata_subparsers = securitydata_arg_parser.add_subparsers(title=u"subcommands") - send_to.init(securitydata_subparsers) - write_to.init(securitydata_subparsers) - print_out.init(securitydata_subparsers) - clear_checkpoint.init(securitydata_subparsers) diff --git a/src/code42cli/securitydata/options.py b/src/code42cli/securitydata/options.py deleted file mode 100644 index f7fa8c45b..000000000 --- a/src/code42cli/securitydata/options.py +++ /dev/null @@ -1,40 +0,0 @@ -class OutputFormat(object): - CEF = "CEF" - JSON = "JSON" - RAW = "RAW-JSON" - - def __iter__(self): - return iter([self.CEF, self.JSON, self.RAW]) - - -class ExposureType(object): - SHARED_VIA_LINK = "SharedViaLink" - SHARED_TO_DOMAIN = "SharedToDomain" - APPLICATION_READ = "ApplicationRead" - CLOUD_STORAGE = "CloudStorage" - REMOVABLE_MEDIA = "RemovableMedia" - IS_PUBLIC = "IsPublic" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [ - self.SHARED_VIA_LINK, - self.SHARED_TO_DOMAIN, - self.APPLICATION_READ, - self.CLOUD_STORAGE, - self.REMOVABLE_MEDIA, - self.IS_PUBLIC, - ] - - -class ServerProtocol(object): - TCP = "TCP" - UDP = "UDP" - - def __iter__(self): - return iter([self.TCP, self.UDP]) diff --git a/src/code42cli/securitydata/subcommands/clear_checkpoint.py b/src/code42cli/securitydata/subcommands/clear_checkpoint.py deleted file mode 100644 index c1768b7ed..000000000 --- a/src/code42cli/securitydata/subcommands/clear_checkpoint.py +++ /dev/null @@ -1,26 +0,0 @@ -from code42cli.arguments import add_profile_name_arg -from code42cli.profile.profile import get_profile -from code42cli.securitydata.cursor_store import FileEventCursorStore - - -def init(subcommand_parser): - """Sets up the `clear-checkpoint` subcommand for cleared the stored checkpoint for `incremental` mode. - Args: - subcommand_parser: The subparsers group created by the parent parser. - """ - parser = subcommand_parser.add_parser( - u"clear-checkpoint", - description=u"Remove the saved checkpoint from 'incremental' (-i) mode.", - usage=u"code42 securitydata clear-checkpoint ", - ) - add_profile_name_arg(parser) - parser.set_defaults(func=clear_checkpoint) - - -def clear_checkpoint(args): - """Removes the stored checkpoint that keeps track of the last event you got. - To use, run `code42 clear-checkpoint`. - This affects `incremental` mode by causing it to behave like it has never been run before. - """ - profile_name = args.profile_name or get_profile().name - FileEventCursorStore(profile_name).replace_stored_insertion_timestamp(None) diff --git a/src/code42cli/securitydata/subcommands/print_out.py b/src/code42cli/securitydata/subcommands/print_out.py deleted file mode 100644 index c518bae2c..000000000 --- a/src/code42cli/securitydata/subcommands/print_out.py +++ /dev/null @@ -1,28 +0,0 @@ -import code42cli.arguments as main_args -from code42cli.securitydata.arguments import main as securitydata_main_args -from code42cli.securitydata.arguments import search as search_args -from code42cli.securitydata.extraction import extract -from code42cli.securitydata.logger_factory import get_logger_for_stdout - - -def init(subcommand_parser): - """Sets up the `print` subcommand. - Use `-h` after any subcommand for usage. - Args: - subcommand_parser: The subparsers group created by the parent parser. - """ - parser = subcommand_parser.add_parser( - u"print", - description=u"Print file events to stdout", - usage=u"code42 securitydata print ", - ) - parser.set_defaults(func=print_out) - search_args.add_arguments_to_parser(parser) - securitydata_main_args.add_arguments_to_parser(parser) - main_args.add_arguments_to_parser(parser) - - -def print_out(args): - """Activates 'print' command. It gets security events and prints them to stdout.""" - logger = get_logger_for_stdout(args.format) - extract(logger, args) diff --git a/src/code42cli/securitydata/subcommands/send_to.py b/src/code42cli/securitydata/subcommands/send_to.py deleted file mode 100644 index 67c4ab479..000000000 --- a/src/code42cli/securitydata/subcommands/send_to.py +++ /dev/null @@ -1,49 +0,0 @@ -import code42cli.arguments as main_args -from code42cli.securitydata.arguments import main as securitydata_main_args -from code42cli.securitydata.arguments import search as search_args -from code42cli.securitydata.extraction import extract -from code42cli.securitydata.logger_factory import get_logger_for_server -from code42cli.securitydata.options import ServerProtocol - - -def init(subcommand_parser): - """Sets up the `send-to` subcommand for sending logs to a server, such as SysLog. - Use `-h` after any subcommand for usage. - Args: - subcommand_parser: The subparsers group created by the parent parser - """ - parser = subcommand_parser.add_parser( - u"send-to", - description=u"Send file events to the given server address.", - usage=u"code42 securitydata send-to ", - ) - parser.set_defaults(func=send_to) - _add_server_arg(parser) - _add_protocol_arg(parser) - search_args.add_arguments_to_parser(parser) - securitydata_main_args.add_arguments_to_parser(parser) - main_args.add_arguments_to_parser(parser) - - -def send_to(args): - """Activates 'send-to' command. It gets security events and logs them to the given server.""" - logger = get_logger_for_server(args.server, args.protocol, args.format) - extract(logger, args) - - -def _add_server_arg(parser): - parser.add_argument( - action=u"store", dest=u"server", help=u"The server address to send output to." - ) - - -def _add_protocol_arg(parser): - parser.add_argument( - u"-p", - u"--protocol", - action=u"store", - dest=u"protocol", - choices=ServerProtocol(), - default=ServerProtocol.UDP, - help=u"Protocol used to send logs to server.", - ) diff --git a/src/code42cli/securitydata/subcommands/write_to.py b/src/code42cli/securitydata/subcommands/write_to.py deleted file mode 100644 index 1bae7b30f..000000000 --- a/src/code42cli/securitydata/subcommands/write_to.py +++ /dev/null @@ -1,35 +0,0 @@ -import code42cli.arguments as main_args -from code42cli.securitydata.arguments import main as securitydata_main_args -from code42cli.securitydata.arguments import search as search_args -from code42cli.securitydata.extraction import extract -from code42cli.securitydata.logger_factory import get_logger_for_file - - -def init(subcommand_parser): - """Sets up the `write-to` subcommand for writing logs to a file. - Use `-h` after any subcommand for usage. - Args: - subcommand_parser: The subparsers group created by the parent parser. - """ - parser = subcommand_parser.add_parser( - u"write-to", - description=u"Write file events to the file with the given name.", - usage=u"code42 securitydata write-to ", - ) - parser.set_defaults(func=write_to) - _add_filename_subcommand(parser) - search_args.add_arguments_to_parser(parser) - securitydata_main_args.add_arguments_to_parser(parser) - main_args.add_arguments_to_parser(parser) - - -def write_to(args): - """Activates 'write-to' command. It gets security events and writes them to the given file.""" - logger = get_logger_for_file(args.output_file, args.format) - extract(logger, args) - - -def _add_filename_subcommand(parser): - parser.add_argument( - action=u"store", dest=u"output_file", help=u"The name of the local file to send output to." - ) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 4274c852d..42bf243f1 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,7 +1,8 @@ from __future__ import print_function, with_statement import sys -from os import path, makedirs + +from os import makedirs, path from code42cli.compat import open @@ -54,13 +55,13 @@ def is_interactive(): def print_no_existing_profile_message(): print_error(u"No existing profile.") - print_set_profile_help() + print_create_profile_help() -def print_set_profile_help(): +def print_create_profile_help(): print(u"") print(u"To add a profile, use: ") - print_bold(u"\tcode42 profile set --profile -s -u ") + print_bold(u"\tcode42 profile create ") print(u"") diff --git a/test.txt b/test.txt new file mode 100644 index 000000000..8631cfdae --- /dev/null +++ b/test.txt @@ -0,0 +1,741 @@ +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_266", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:08.054Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunJenkinsSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 503, "fileOwner": "kathy.kane", "md5Checksum": "31e9b26ca9caeafd44b1d81d7fd216c3", "sha256Checksum": "e0de2ec27a9bb5ba229cd38c47d3015ab20345a6a92a0b2e3e8276c2e104bfa7", "createTimestamp": "2020-04-01T11:49:19.390Z", "modifyTimestamp": "2020-04-01T11:49:21.102Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_274", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:27.325Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver.exe", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Executable", "fileSize": 8543232, "fileOwner": "kathy.kane", "md5Checksum": "8ee62a8925030966a240521561e13f5a", "sha256Checksum": "66cfa645f83fde41720beac7061a559fd57b6f5caa83d7918f44de0f4dd27845", "createTimestamp": "2020-04-01T11:47:08.616Z", "modifyTimestamp": "2020-04-01T11:47:11.721Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-msdownload", "mimeTypeByExtension": "application/x-dosexec", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_269", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:07.038Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunSingleSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 490, "fileOwner": "kathy.kane", "md5Checksum": "075169d962d428547131e8669343b64b", "sha256Checksum": "336372de237f7f355550fdf8e48294c24a931f57a244176f58379e14f78d6f01", "createTimestamp": "2020-04-01T11:49:24.618Z", "modifyTimestamp": "2020-04-01T11:49:26.509Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_272", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:23.245Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Uncategorized", "fileSize": 14713200, "fileOwner": "kathy.kane", "md5Checksum": "f8999bb031325631ec685aba3c3266f5", "sha256Checksum": "b91856fda0fc769d8781dac5592b3f776f16b45b82b23fd636d45646e7d5d1f5", "createTimestamp": "2020-04-01T11:47:22.050Z", "modifyTimestamp": "2020-04-01T11:47:23.711Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-mach-o", "mimeTypeByExtension": "application/octet-stream", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_12", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.019Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.328Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_10", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.298Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_13", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.057Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.332Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_11", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.983Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.324Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_410", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.752Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToDecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1137, "fileOwner": "kathy.kane", "md5Checksum": "22f1e7d589972ca5fad60c8519d20e54", "sha256Checksum": "91fd221bf07accb12fb54f8a24349442a70a6f1e2a784e02d7b54c8183805613", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.765Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_411", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.736Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToHexadecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1642, "fileOwner": "kathy.kane", "md5Checksum": "985232edbb7900aa3def0a349718265e", "sha256Checksum": "0a9745b02fff401f03afbf571f11465373edaa1f63a8cb6f6503f4a5768ef9a2", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.827Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_409", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:51.298Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "IntegerToRoman.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1149, "fileOwner": "kathy.kane", "md5Checksum": "c10dce754394e1d1af170a9be3fef3f4", "sha256Checksum": "0c1bdae526817ae624223a8d3231ba3e1b6e8f67708e2db7eda1150477e7414a", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.886Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_412", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:53.816Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "RomanToInteger.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1441, "fileOwner": "kathy.kane", "md5Checksum": "d92e8215a4b799c8f9a2dec10218ab01", "sha256Checksum": "e2cd78b8a1a258b114648240eeeef7bdec5e68e54713174e0a01c0a7bb72a46c", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.796Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_80", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.784Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_81", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.815Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_79", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.753Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.237Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_78", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.034Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.472Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_294", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.897Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:31.116Z", "modifyTimestamp": "2019-08-12T16:41:56.988Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_292", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.554Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:31.038Z", "modifyTimestamp": "2019-08-12T16:41:57.139Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_293", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.803Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:31.069Z", "modifyTimestamp": "2019-08-12T16:41:55.779Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_291", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.366Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:31.007Z", "modifyTimestamp": "2019-08-12T16:41:56.842Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_324", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:58.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "MSA - Lackawanna Touring Company.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Document", "fileSize": 382094, "fileOwner": "kathy.kane", "md5Checksum": "39e21b6e0a1d4902c98baa5e3aeaba19", "sha256Checksum": "854156252e3ca1024050b7c20e76b3ede6649a48a3980899ef04ab9df534abc5", "createTimestamp": "2020-04-01T10:43:57.354Z", "modifyTimestamp": "2020-04-01T10:44:00.510Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_323", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.879Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "LTC - DC Replacement Project Plan.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 13635, "fileOwner": "kathy.kane", "md5Checksum": "3ef51bbb881c915bba30a6796553c005", "sha256Checksum": "4c3d8223b02f4299c80c0590dddd4c206f00b89419753fd9301b8cc992aa5fe9", "createTimestamp": "2020-04-01T10:43:36.417Z", "modifyTimestamp": "2020-04-01T10:43:39.729Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_322", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Lackawanna Touring Company.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Spreadsheet", "fileSize": 32354, "fileOwner": "kathy.kane", "md5Checksum": "aab45b5dd52dccb21a0e7e18bff9229e", "sha256Checksum": "90fa1ba4dfd2624c66e13ed6de7e676fb3558d2e4dd424aa2bbb5740b65b31cf", "createTimestamp": "2020-04-01T10:43:44.916Z", "modifyTimestamp": "2020-04-01T10:43:48.385Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_687", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:04.665Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "ZOOOOOOMYBoi.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 22137371, "fileOwner": "darnell.waters", "md5Checksum": "124fa909c632f80b70f016eecf440fd3", "sha256Checksum": "043173fb09f1001dcad6934dfd988b6fe91f6f03982dcc92dfe0292a93a4e803", "createTimestamp": "2020-02-06T15:42:20Z", "modifyTimestamp": "2020-02-19T19:11:17.378Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_685", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:49:09.123Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "GotWings.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 12654813, "fileOwner": "darnell.waters", "md5Checksum": "84958f28d8e3f0af82a9143fa98edc92", "sha256Checksum": "771acf81676efa85688fed2b7b0850a75cf6857d5998e9eab7c4247a3a48314e", "createTimestamp": "2020-02-06T15:25:30Z", "modifyTimestamp": "2020-02-19T19:11:18.539Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_686", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:20.332Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "THEBOSS.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 28262513, "fileOwner": "darnell.waters", "md5Checksum": "62eda4aada3ee1c7b18ab10970636b54", "sha256Checksum": "15f9d5e9ef79a3d6755b6df9b8406f3d0adf4abbab07d2b7df5645f71530554f", "createTimestamp": "2020-02-06T15:22:40Z", "modifyTimestamp": "2020-02-19T19:11:19.568Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_183", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:46:19.896Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_182", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:45:57.608Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_82", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.948Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-devportal.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1447, "fileOwner": "sean.cassidy", "md5Checksum": "0beee3cec377487154903f2d213c37fe", "sha256Checksum": "5a810a00d365c563314808e7c7934e531f327277e00ca6267976f036a170d28c", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.658Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_86", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.986Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-redis.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2100, "fileOwner": "sean.cassidy", "md5Checksum": "a340a797bd8e0981bf9dc9f3b4cd6f0c", "sha256Checksum": "f4a2b19821e2c8f096b2e74663bb0d2664046edf6b9f5c4b736b860c55ec933a", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.736Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_90", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.003Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds-rbac.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2004, "fileOwner": "sean.cassidy", "md5Checksum": "3905c4678af557eb44841c4bb2525b80", "sha256Checksum": "784d7f9cc3d709b7e1e7dbbfaa9027a887263ff78398f4ef4a5e0b43e1e64173", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.829Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_81", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.965Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "admin-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1491, "fileOwner": "sean.cassidy", "md5Checksum": "f61050ab8def08a384bbd0bed47c8cd6", "sha256Checksum": "84242f6fefff710efc16971b44479f81881ccccd3e96bc07a35c29ae99a04178", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.626Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_87", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.966Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2680, "fileOwner": "sean.cassidy", "md5Checksum": "080fc77a1284b0439dc8218df43668a9", "sha256Checksum": "5ad11226c30229686464543324255046f4d5a89c19f1fb6fda674b44f6c9fce3", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.752Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_83", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.928Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-auth.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1203, "fileOwner": "sean.cassidy", "md5Checksum": "d9b52beb12fd195f8bf347c4ea95df62", "sha256Checksum": "c2b67cb056dc7e4fa82dac3d3b18091922619c02e2783247fcb2c068987944d6", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.673Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_85", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:28.983Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-ratelimit.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 371, "fileOwner": "sean.cassidy", "md5Checksum": "ee51e7c14f3bafb58ea317d2173c1b79", "sha256Checksum": "86fec44093ad5c8aee2dd98f4686eb0e9b8fc98d8e0e5a5e4b762b47fb30c372", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.720Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_84", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.007Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-license-key-secret.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 227, "fileOwner": "sean.cassidy", "md5Checksum": "fd89e26a07fa4f8503fd40259f6d43d5", "sha256Checksum": "7395defcf955595295ba8c3ce16890fc4ee987311b2c801b1fc6a31a03053307", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.689Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_91", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:24.418Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 136, "fileOwner": "sean.cassidy", "md5Checksum": "fe3a88fb7c4f3032ddc75a50844d42fd", "sha256Checksum": "240083c41b206ada276328f0988b28a140bfd09f3b884e80463557db69d29d18", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.845Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_89", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.923Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crd-delete.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1621, "fileOwner": "sean.cassidy", "md5Checksum": "41b4d9e96a10d80087088eb06e3d92bd", "sha256Checksum": "b4fcecdc5b9a440d976e27adaa99d48d5eeacbc9a8c98827b0ce4c3a43f4cf01", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.798Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_88", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.946Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "config.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 605, "fileOwner": "sean.cassidy", "md5Checksum": "8042961777d6ee44573224233a9687ea", "sha256Checksum": "089cd64bda07824df9b16a51d9f9b2c3c3dd835624c39c6fb39bab562b65f038", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.783Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947792142030207362_434", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:24:35.336Z", "insertionTimestamp": "2020-03-31T17:29:02.459Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/", "fileName": "configure.py", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 57602, "fileOwner": "sean.cassidy", "md5Checksum": "75a4c54c9421b296c0a63a044029fad5", "sha256Checksum": "8ab6290f42c53c940f08f4fbe520ebd5e72d1dc85683b17783e38b89280f1a41", "createTimestamp": "2020-03-07T17:41:27.411Z", "modifyTimestamp": "2020-03-31T17:24:07.424Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.4.0\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-python", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947791329686485442_62", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:18:33.189Z", "insertionTimestamp": "2020-03-31T17:20:56.912Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "your-marketing-plan-template.doc", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Document", "fileSize": 45568, "fileOwner": "Administrators", "md5Checksum": "6bb8604e540d3df44f18db72dfd5908f", "sha256Checksum": "4e121eed4819e5586930844475505da498f9ce424d3d43595a0d3473bfada2fc", "createTimestamp": "2019-02-07T16:23:05.662Z", "modifyTimestamp": "2019-02-07T16:23:06.908Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (54) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-tika-msoffice", "mimeTypeByExtension": "application/msword", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947790854009780610_771", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:02:39.749Z", "insertionTimestamp": "2020-03-31T17:16:14.843Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/HashMaker/", "fileName": "BlockAllocator.h", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "SourceCode", "fileCategoryByExtension": "SourceCode", "fileSize": 3549, "fileOwner": "sean.cassidy", "md5Checksum": "601f6f6fc877d60922b9c1012370232c", "sha256Checksum": "f57cae2718ffea77ddb86fb0f95b214651626b167712ae2d0f9306259a7a6907", "createTimestamp": "2020-03-19T01:38:00Z", "modifyTimestamp": "2020-03-19T03:43:46.973Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.3.1\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/x-csrc", "mimeTypeByExtension": "text/x-chdr", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947790235711338946_143", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:08:56.530Z", "insertionTimestamp": "2020-03-31T17:10:04.773Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "The Chiropractic Report Chapman Referral Letters.PDF", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 503962, "fileOwner": "Administrators", "md5Checksum": "9c0b34317626ab2b393d48e8f726569e", "sha256Checksum": "0536562c0e47848c6dcab72cade08eefeea5a0c67cb3c0b92f79d7b585522807", "createTimestamp": "2018-10-02T19:13:47.218Z", "modifyTimestamp": "2018-03-21T21:22:48.303Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (53) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_411", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:29.955Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T16:57:23.132Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_410", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.063Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T16:57:23.141Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_409", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.028Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T16:57:23.131Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_269", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.207Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_271", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.218Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_270", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.214Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_266", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.189Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_265", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.188Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_260", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.215Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_259", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.190Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_263", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.222Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_262", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.221Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_261", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.217Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_258", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.186Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_257", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.183Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_268", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.206Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_256", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.181Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_267", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.192Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_264", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.184Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_117", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.327Z", "insertionTimestamp": "2020-03-31T11:04:15.595Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T11:00:48.869Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_115", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:51.545Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T11:00:48.353Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_116", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.389Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T11:00:48.885Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "643502901225__8749df16-e136-4268-bc85-5323f8db2597", "eventType": "MODIFIED", "eventTimestamp": "2020-03-31T03:08:06.978Z", "insertionTimestamp": "2020-03-31T09:02:25.372Z", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 56653, "fileOwner": "kathy.kane@c42se.com", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:17:38Z", "modifyTimestamp": "2020-03-30T12:17:38Z", "actor": "kathy.kane@c42se.com", "directoryId": ["108056515629"], "source": "Box", "url": "https://code42a.box.com/s/sblis4r0zr5p0rbrr87fu3zml8svej58", "shared": "TRUE", "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "9981852168", "detectionSourceAlias": "C42 SE Box", "fileId": "643502901225", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk_2_9164694a-48e8-4c89-aed8-36d51d6338d4", "eventType": "CREATED", "eventTimestamp": "2020-03-30T15:29:52.894Z", "insertionTimestamp": "2020-03-31T00:01:48.913Z", "fileName": "9.29 Meeting Notes.txt", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "Document", "fileSize": 8089, "fileOwner": "george.washington@c42se.com", "md5Checksum": "86eb5a3c9d0ea6b6c37d3f988f42c718", "sha256Checksum": "4468e007b9b4a8050c10d29b3c9b38ea66d896389b1c27c4d030e129ab0ab688", "createTimestamp": "2020-03-30T15:05:27.880Z", "modifyTimestamp": "2020-03-30T15:05:39.871Z", "actor": "george.washington@c42se.com", "directoryId": ["0AB20OqRQS81NUk9PVA"], "source": "GoogleDrive", "url": "https://drive.google.com/a/c42se.com/file/d/1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk/view?usp=drivesdk", "shared": "TRUE", "sharedWith": [{"cloudUsername": "External (Public)"}], "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "0AB20OqRQS81NUk9PVA", "detectionSourceAlias": "C42SE GDrive2", "fileId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/plain", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_213", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.086Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_210", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.079Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_201", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.075Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_199", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.992Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_207", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.037Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_205", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.090Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_204", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.088Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_202", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.077Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_211", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.082Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_200", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.014Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_198", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.988Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_208", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.071Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_212", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.084Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_209", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.074Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_206", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.012Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_203", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.080Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_169", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.759Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-19.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 228687, "fileOwner": "Administrators", "md5Checksum": "9da3457f38edd0e046c933175f46ca24", "sha256Checksum": "1d59d2c941afc4edc177ca6ea4bff0a0ff85b30c3d36498a68c46c157e93ebe5", "createTimestamp": "2019-02-07T18:16:40.414Z", "modifyTimestamp": "2019-02-07T18:16:40.781Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_167", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:18.732Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2019-02-07T18:14:54.402Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_172", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:14.775Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:34:54.617Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_168", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.788Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2019-02-07T18:21:40.645Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_171", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:16.780Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2018.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 888674, "fileOwner": "Administrators", "md5Checksum": "dd0bc4b60d44899ec14fedb3ba6e4ad9", "sha256Checksum": "4855a7290e8c0cb70ce2f12a7bd08ed0238d10176c54b78f79c27e309a56eb10", "createTimestamp": "2019-02-07T18:20:12.547Z", "modifyTimestamp": "2019-02-07T18:20:12.985Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_170", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.727Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-20.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 222829, "fileOwner": "Administrators", "md5Checksum": "de85d81335b089f30c3397e1174781e1", "sha256Checksum": "e049fc0fd048a49a8d0a581cd221af288d2f5882d7b88a88b46611e2037113aa", "createTimestamp": "2020-03-30T14:35:19.754Z", "modifyTimestamp": "2020-03-30T14:35:19.817Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_674", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:46.083Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:36:45.974Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_673", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.910Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-30T14:36:32.692Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_672", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.848Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-30T14:36:32.676Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_3", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_1", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:07.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_0", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_2", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_864", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_862", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_860", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_853", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_844", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_826", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "MississippiCloud1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 3897, "fileOwner": "jennifer.vang", "md5Checksum": "d790364577802d43b28e38249a4f01ef", "sha256Checksum": "7e22b9c6c7a19380acd28d699f866a0ee417b57f25b3e4240b95a34951b35685", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_822", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_814", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_811", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_859", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:25.813Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_851", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dragon-pan-_7l2FS4FicM-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10326110, "fileOwner": "jennifer.vang", "md5Checksum": "3a6aad3c9dea5aa2b04a84343270d767", "sha256Checksum": "3e7d1339057b496fe8d395c9cdbd7737a2da76f8d0c850503d175d209b2bb3c9", "createTimestamp": "2020-02-12T12:46:12Z", "modifyTimestamp": "2020-02-12T12:46:12Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_843", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:38.395Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_838", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:44.923Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_837", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_817", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_815", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "paul-hanaoka-a104tlUezug-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2818933, "fileOwner": "jennifer.vang", "md5Checksum": "68cc9c9d063c95303fafbc4a9a8b2d97", "sha256Checksum": "170779a12338948bff1e88aea7fd0c03d90b1c66fcb297f6476b1a4ec0ea82d5", "createTimestamp": "2020-02-12T12:45:52Z", "modifyTimestamp": "2020-02-12T12:45:52Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_813", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_872", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_833", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_830", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_828", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_821", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_819", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_873", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_867", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_852", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:11.204Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_834", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2018-12-10T21:28:34Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_824", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_820", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_870", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_869", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-12T12:46:26Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_866", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_863", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_850", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_842", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:39.654Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_841", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_839", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_829", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_865", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_849", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_847", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_835", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_871", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_861", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_856", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-13T16:10:19.557Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_836", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:53.508Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_818", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:29.161Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_858", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:23.312Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_857", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_855", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_840", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_831", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_827", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_868", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_854", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_848", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_846", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_845", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_832", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:47.431Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_825", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_823", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_816", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_808", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_807", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-12T12:46:18Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_804", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-12T12:46:34Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_793", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_791", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_790", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_783", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_774", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_767", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_763", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:15.688Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_760", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-12T12:46:20Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_744", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_801", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_786", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_785", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (114 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10416961, "fileOwner": "jennifer.vang", "md5Checksum": "b816ee1bf58595d8e5cd7a923e3eb8c9", "sha256Checksum": "a66691b862c63895e55079bce5a3a76c0b4863a436953549a802a872fe6bf4a2", "createTimestamp": "2018-12-10T21:30:22Z", "modifyTimestamp": "2018-12-10T21:30:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_756", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_752", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_745", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_803", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_766", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_751", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_742", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_799", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_796", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-12T12:55:04Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_795", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_772", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_769", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:18.105Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_762", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-12T12:46:10Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_757", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_748", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_780", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_775", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_770", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_768", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_765", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_764", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_755", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_743", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_737", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Cooper DB Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662448, "fileOwner": "jennifer.vang", "md5Checksum": "5b0ed4af0e989bde0339bc19ad61a8c3", "sha256Checksum": "390ec485088c848de1a5f260e220fcc0653651291b66124b80b11c14f9e6ff65", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_735", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "JaleelCRM2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4497, "fileOwner": "jennifer.vang", "md5Checksum": "741ef1acf2071b0f60d8487677f68e16", "sha256Checksum": "bc5b8e0924de3b4b143ac35201b85393015172cb8e894c879cf14d409669cc21", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_806", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_802", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_798", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_794", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_792", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_788", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_771", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_749", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:42.528Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_739", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.06.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407551, "fileOwner": "jennifer.vang", "md5Checksum": "72d06b6a958228513904082c644e0902", "sha256Checksum": "2cc02f5b08f626c9390851edbac05829d154e640635e49b6fb64b7f2647ffc61", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_805", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:20.918Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_784", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_778", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "BlackHornetStoryboard.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 1893, "fileOwner": "jennifer.vang", "md5Checksum": "3732d8580df54e63269e397eeba3de7d", "sha256Checksum": "b11ebfa2c83029db1929a18a2dbaf47fe1abda8c7af72882dcc3339d901ec958", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_776", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_773", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_758", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_753", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_747", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_746", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_738", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_736", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "MississippiCloud3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2657, "fileOwner": "jennifer.vang", "md5Checksum": "2b732f159cdaff64fee6bf922f4e6901", "sha256Checksum": "5ae69936410f58eaf5f290d753ba1e59daee654ea7035c30d665dbb7e469febc", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_797", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_789", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_777", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-12T12:47:00Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_754", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_740", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_733", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_809", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_800", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_787", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (119 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 11786180, "fileOwner": "jennifer.vang", "md5Checksum": "5fa18acf4fc97eb4bf8632a60b956a6c", "sha256Checksum": "4228073c4aee56559d664c0f35c02d7bea17809dc9a4be7efa890ad7e49a81a5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_782", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_781", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "Longfellow North Campus Network Diagram.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 6733, "fileOwner": "jennifer.vang", "md5Checksum": "0df46da580a4acb02e9e509da7e2ec32", "sha256Checksum": "8605a5edb90daeef9047f99bbd676de3c51b59d19ff44eefe4b4d1f89674c24e", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_779", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_761", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_759", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_750", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:44.240Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_741", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:45.628Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_734", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941983451917189059_947621656901949516_346", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:15.756Z", "insertionTimestamp": "2020-03-30T13:15:24.556Z", "filePath": "C:/Users/darnell.waters/OneDrive - Code42/", "fileName": ".849C9593-D756-4E56-8D6E-42412F2A707B", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 63, "fileOwner": "darnell.waters", "md5Checksum": "e37ee15a01960b22e4ece7f055532215", "sha256Checksum": "002d0c0a9f80d3bb5df04547e533553d4046d008bb88807627801157276b535c", "createTimestamp": "2020-02-19T21:48:30.549Z", "modifyTimestamp": "2020-03-30T12:51:09.790Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.39", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.39", "fe80:0:0:0:1d77:dcdf:c593:1143%eth2", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "941983451917189059", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "OneDrive", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_731", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:03.757Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Inscents.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 32346, "fileOwner": "kathy.kane", "md5Checksum": "6ec589b2e49feebe91b29c447c34fd99", "sha256Checksum": "b9fd589c001b4e8d96d2238e42412f80e039456d91f42fefebdfd055ed56504a", "createTimestamp": "2020-03-30T12:17:14.785Z", "modifyTimestamp": "2020-03-30T12:17:18.098Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_730", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:08.695Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "kathy.kane", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:14:10.543Z", "modifyTimestamp": "2020-03-30T12:14:12.757Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.430Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T11:53:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_811", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.461Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_809", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.368Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T12:02:48Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/octet-stream", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.492Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947613460610701533_502", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:03.888Z", "insertionTimestamp": "2020-03-30T11:53:59.251Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "SAC_Book_SecurityAwarenessPlaybook.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 3125834, "fileOwner": "jordan.anderson", "md5Checksum": "af3a63b4bbe732f1b7f17694e1762de8", "sha256Checksum": "7c558f359788befa3700e3c901caeb738ebc2475803cc347bebf42a692ee8724", "createTimestamp": "2019-05-22T17:17:57.425Z", "modifyTimestamp": "2019-05-22T17:17:59.481Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612912599718109_334", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:00.980Z", "insertionTimestamp": "2020-03-30T11:48:34.115Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "N-SOS-022_TheGlobalCostOfInsecurity.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 562441, "fileOwner": "jordan.anderson", "md5Checksum": "9d5b6ced2937c1bac8231e259560f0d4", "sha256Checksum": "0b3660bccde1197d521ca10d532f5e978ca31e78552466842c8c74e0fe5012fa", "createTimestamp": "2019-05-22T17:18:05.266Z", "modifyTimestamp": "2019-05-22T17:18:06.653Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612023390241449_126", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:36:55.028Z", "insertionTimestamp": "2020-03-30T11:39:43.734Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "jordan.anderson", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T11:20:50.858Z", "modifyTimestamp": "2020-03-30T11:20:55.671Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Inbox (9) - jordan.anderson@c42se.com - Code42 SE Mail - Mozilla Firefox"], "tabUrl": "https://mail.google.com/mail/u/0/#inbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_266", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:08.054Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunJenkinsSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 503, "fileOwner": "kathy.kane", "md5Checksum": "31e9b26ca9caeafd44b1d81d7fd216c3", "sha256Checksum": "e0de2ec27a9bb5ba229cd38c47d3015ab20345a6a92a0b2e3e8276c2e104bfa7", "createTimestamp": "2020-04-01T11:49:19.390Z", "modifyTimestamp": "2020-04-01T11:49:21.102Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_274", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:27.325Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver.exe", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Executable", "fileSize": 8543232, "fileOwner": "kathy.kane", "md5Checksum": "8ee62a8925030966a240521561e13f5a", "sha256Checksum": "66cfa645f83fde41720beac7061a559fd57b6f5caa83d7918f44de0f4dd27845", "createTimestamp": "2020-04-01T11:47:08.616Z", "modifyTimestamp": "2020-04-01T11:47:11.721Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-msdownload", "mimeTypeByExtension": "application/x-dosexec", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_269", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:07.038Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunSingleSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 490, "fileOwner": "kathy.kane", "md5Checksum": "075169d962d428547131e8669343b64b", "sha256Checksum": "336372de237f7f355550fdf8e48294c24a931f57a244176f58379e14f78d6f01", "createTimestamp": "2020-04-01T11:49:24.618Z", "modifyTimestamp": "2020-04-01T11:49:26.509Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_272", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:23.245Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Uncategorized", "fileSize": 14713200, "fileOwner": "kathy.kane", "md5Checksum": "f8999bb031325631ec685aba3c3266f5", "sha256Checksum": "b91856fda0fc769d8781dac5592b3f776f16b45b82b23fd636d45646e7d5d1f5", "createTimestamp": "2020-04-01T11:47:22.050Z", "modifyTimestamp": "2020-04-01T11:47:23.711Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-mach-o", "mimeTypeByExtension": "application/octet-stream", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_12", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.019Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.328Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_10", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.298Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_13", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.057Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.332Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_11", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.983Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.324Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_410", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.752Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToDecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1137, "fileOwner": "kathy.kane", "md5Checksum": "22f1e7d589972ca5fad60c8519d20e54", "sha256Checksum": "91fd221bf07accb12fb54f8a24349442a70a6f1e2a784e02d7b54c8183805613", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.765Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_411", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.736Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToHexadecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1642, "fileOwner": "kathy.kane", "md5Checksum": "985232edbb7900aa3def0a349718265e", "sha256Checksum": "0a9745b02fff401f03afbf571f11465373edaa1f63a8cb6f6503f4a5768ef9a2", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.827Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_409", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:51.298Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "IntegerToRoman.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1149, "fileOwner": "kathy.kane", "md5Checksum": "c10dce754394e1d1af170a9be3fef3f4", "sha256Checksum": "0c1bdae526817ae624223a8d3231ba3e1b6e8f67708e2db7eda1150477e7414a", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.886Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_412", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:53.816Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "RomanToInteger.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1441, "fileOwner": "kathy.kane", "md5Checksum": "d92e8215a4b799c8f9a2dec10218ab01", "sha256Checksum": "e2cd78b8a1a258b114648240eeeef7bdec5e68e54713174e0a01c0a7bb72a46c", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.796Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_80", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.784Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_81", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.815Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_79", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.753Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.237Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_78", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.034Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.472Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_294", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.897Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:31.116Z", "modifyTimestamp": "2019-08-12T16:41:56.988Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_292", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.554Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:31.038Z", "modifyTimestamp": "2019-08-12T16:41:57.139Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_293", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.803Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:31.069Z", "modifyTimestamp": "2019-08-12T16:41:55.779Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_291", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.366Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:31.007Z", "modifyTimestamp": "2019-08-12T16:41:56.842Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_324", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:58.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "MSA - Lackawanna Touring Company.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Document", "fileSize": 382094, "fileOwner": "kathy.kane", "md5Checksum": "39e21b6e0a1d4902c98baa5e3aeaba19", "sha256Checksum": "854156252e3ca1024050b7c20e76b3ede6649a48a3980899ef04ab9df534abc5", "createTimestamp": "2020-04-01T10:43:57.354Z", "modifyTimestamp": "2020-04-01T10:44:00.510Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_323", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.879Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "LTC - DC Replacement Project Plan.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 13635, "fileOwner": "kathy.kane", "md5Checksum": "3ef51bbb881c915bba30a6796553c005", "sha256Checksum": "4c3d8223b02f4299c80c0590dddd4c206f00b89419753fd9301b8cc992aa5fe9", "createTimestamp": "2020-04-01T10:43:36.417Z", "modifyTimestamp": "2020-04-01T10:43:39.729Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_322", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Lackawanna Touring Company.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Spreadsheet", "fileSize": 32354, "fileOwner": "kathy.kane", "md5Checksum": "aab45b5dd52dccb21a0e7e18bff9229e", "sha256Checksum": "90fa1ba4dfd2624c66e13ed6de7e676fb3558d2e4dd424aa2bbb5740b65b31cf", "createTimestamp": "2020-04-01T10:43:44.916Z", "modifyTimestamp": "2020-04-01T10:43:48.385Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_687", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:04.665Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "ZOOOOOOMYBoi.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 22137371, "fileOwner": "darnell.waters", "md5Checksum": "124fa909c632f80b70f016eecf440fd3", "sha256Checksum": "043173fb09f1001dcad6934dfd988b6fe91f6f03982dcc92dfe0292a93a4e803", "createTimestamp": "2020-02-06T15:42:20Z", "modifyTimestamp": "2020-02-19T19:11:17.378Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_685", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:49:09.123Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "GotWings.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 12654813, "fileOwner": "darnell.waters", "md5Checksum": "84958f28d8e3f0af82a9143fa98edc92", "sha256Checksum": "771acf81676efa85688fed2b7b0850a75cf6857d5998e9eab7c4247a3a48314e", "createTimestamp": "2020-02-06T15:25:30Z", "modifyTimestamp": "2020-02-19T19:11:18.539Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_686", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:20.332Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "THEBOSS.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 28262513, "fileOwner": "darnell.waters", "md5Checksum": "62eda4aada3ee1c7b18ab10970636b54", "sha256Checksum": "15f9d5e9ef79a3d6755b6df9b8406f3d0adf4abbab07d2b7df5645f71530554f", "createTimestamp": "2020-02-06T15:22:40Z", "modifyTimestamp": "2020-02-19T19:11:19.568Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_183", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:46:19.896Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_182", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:45:57.608Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_82", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.948Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-devportal.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1447, "fileOwner": "sean.cassidy", "md5Checksum": "0beee3cec377487154903f2d213c37fe", "sha256Checksum": "5a810a00d365c563314808e7c7934e531f327277e00ca6267976f036a170d28c", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.658Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_86", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.986Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-redis.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2100, "fileOwner": "sean.cassidy", "md5Checksum": "a340a797bd8e0981bf9dc9f3b4cd6f0c", "sha256Checksum": "f4a2b19821e2c8f096b2e74663bb0d2664046edf6b9f5c4b736b860c55ec933a", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.736Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_90", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.003Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds-rbac.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2004, "fileOwner": "sean.cassidy", "md5Checksum": "3905c4678af557eb44841c4bb2525b80", "sha256Checksum": "784d7f9cc3d709b7e1e7dbbfaa9027a887263ff78398f4ef4a5e0b43e1e64173", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.829Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_81", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.965Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "admin-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1491, "fileOwner": "sean.cassidy", "md5Checksum": "f61050ab8def08a384bbd0bed47c8cd6", "sha256Checksum": "84242f6fefff710efc16971b44479f81881ccccd3e96bc07a35c29ae99a04178", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.626Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_87", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.966Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2680, "fileOwner": "sean.cassidy", "md5Checksum": "080fc77a1284b0439dc8218df43668a9", "sha256Checksum": "5ad11226c30229686464543324255046f4d5a89c19f1fb6fda674b44f6c9fce3", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.752Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_83", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.928Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-auth.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1203, "fileOwner": "sean.cassidy", "md5Checksum": "d9b52beb12fd195f8bf347c4ea95df62", "sha256Checksum": "c2b67cb056dc7e4fa82dac3d3b18091922619c02e2783247fcb2c068987944d6", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.673Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_85", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:28.983Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-ratelimit.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 371, "fileOwner": "sean.cassidy", "md5Checksum": "ee51e7c14f3bafb58ea317d2173c1b79", "sha256Checksum": "86fec44093ad5c8aee2dd98f4686eb0e9b8fc98d8e0e5a5e4b762b47fb30c372", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.720Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_84", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.007Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-license-key-secret.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 227, "fileOwner": "sean.cassidy", "md5Checksum": "fd89e26a07fa4f8503fd40259f6d43d5", "sha256Checksum": "7395defcf955595295ba8c3ce16890fc4ee987311b2c801b1fc6a31a03053307", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.689Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_91", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:24.418Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 136, "fileOwner": "sean.cassidy", "md5Checksum": "fe3a88fb7c4f3032ddc75a50844d42fd", "sha256Checksum": "240083c41b206ada276328f0988b28a140bfd09f3b884e80463557db69d29d18", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.845Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_89", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.923Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crd-delete.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1621, "fileOwner": "sean.cassidy", "md5Checksum": "41b4d9e96a10d80087088eb06e3d92bd", "sha256Checksum": "b4fcecdc5b9a440d976e27adaa99d48d5eeacbc9a8c98827b0ce4c3a43f4cf01", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.798Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_88", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.946Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "config.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 605, "fileOwner": "sean.cassidy", "md5Checksum": "8042961777d6ee44573224233a9687ea", "sha256Checksum": "089cd64bda07824df9b16a51d9f9b2c3c3dd835624c39c6fb39bab562b65f038", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.783Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947792142030207362_434", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:24:35.336Z", "insertionTimestamp": "2020-03-31T17:29:02.459Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/", "fileName": "configure.py", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 57602, "fileOwner": "sean.cassidy", "md5Checksum": "75a4c54c9421b296c0a63a044029fad5", "sha256Checksum": "8ab6290f42c53c940f08f4fbe520ebd5e72d1dc85683b17783e38b89280f1a41", "createTimestamp": "2020-03-07T17:41:27.411Z", "modifyTimestamp": "2020-03-31T17:24:07.424Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.4.0\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-python", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947791329686485442_62", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:18:33.189Z", "insertionTimestamp": "2020-03-31T17:20:56.912Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "your-marketing-plan-template.doc", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Document", "fileSize": 45568, "fileOwner": "Administrators", "md5Checksum": "6bb8604e540d3df44f18db72dfd5908f", "sha256Checksum": "4e121eed4819e5586930844475505da498f9ce424d3d43595a0d3473bfada2fc", "createTimestamp": "2019-02-07T16:23:05.662Z", "modifyTimestamp": "2019-02-07T16:23:06.908Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (54) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-tika-msoffice", "mimeTypeByExtension": "application/msword", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947790854009780610_771", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:02:39.749Z", "insertionTimestamp": "2020-03-31T17:16:14.843Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/HashMaker/", "fileName": "BlockAllocator.h", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "SourceCode", "fileCategoryByExtension": "SourceCode", "fileSize": 3549, "fileOwner": "sean.cassidy", "md5Checksum": "601f6f6fc877d60922b9c1012370232c", "sha256Checksum": "f57cae2718ffea77ddb86fb0f95b214651626b167712ae2d0f9306259a7a6907", "createTimestamp": "2020-03-19T01:38:00Z", "modifyTimestamp": "2020-03-19T03:43:46.973Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.3.1\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/x-csrc", "mimeTypeByExtension": "text/x-chdr", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947790235711338946_143", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:08:56.530Z", "insertionTimestamp": "2020-03-31T17:10:04.773Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "The Chiropractic Report Chapman Referral Letters.PDF", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 503962, "fileOwner": "Administrators", "md5Checksum": "9c0b34317626ab2b393d48e8f726569e", "sha256Checksum": "0536562c0e47848c6dcab72cade08eefeea5a0c67cb3c0b92f79d7b585522807", "createTimestamp": "2018-10-02T19:13:47.218Z", "modifyTimestamp": "2018-03-21T21:22:48.303Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (53) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_411", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:29.955Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T16:57:23.132Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_410", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.063Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T16:57:23.141Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_409", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.028Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T16:57:23.131Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_269", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.207Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_271", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.218Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_270", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.214Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_266", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.189Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_265", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.188Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_260", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.215Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_259", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.190Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_263", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.222Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_262", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.221Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_261", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.217Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_258", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.186Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_257", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.183Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_268", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.206Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_256", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.181Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_267", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.192Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_264", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.184Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_117", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.327Z", "insertionTimestamp": "2020-03-31T11:04:15.595Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T11:00:48.869Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_115", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:51.545Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T11:00:48.353Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_116", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.389Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T11:00:48.885Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "643502901225__8749df16-e136-4268-bc85-5323f8db2597", "eventType": "MODIFIED", "eventTimestamp": "2020-03-31T03:08:06.978Z", "insertionTimestamp": "2020-03-31T09:02:25.372Z", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 56653, "fileOwner": "kathy.kane@c42se.com", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:17:38Z", "modifyTimestamp": "2020-03-30T12:17:38Z", "actor": "kathy.kane@c42se.com", "directoryId": ["108056515629"], "source": "Box", "url": "https://code42a.box.com/s/sblis4r0zr5p0rbrr87fu3zml8svej58", "shared": "TRUE", "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "9981852168", "detectionSourceAlias": "C42 SE Box", "fileId": "643502901225", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk_2_9164694a-48e8-4c89-aed8-36d51d6338d4", "eventType": "CREATED", "eventTimestamp": "2020-03-30T15:29:52.894Z", "insertionTimestamp": "2020-03-31T00:01:48.913Z", "fileName": "9.29 Meeting Notes.txt", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "Document", "fileSize": 8089, "fileOwner": "george.washington@c42se.com", "md5Checksum": "86eb5a3c9d0ea6b6c37d3f988f42c718", "sha256Checksum": "4468e007b9b4a8050c10d29b3c9b38ea66d896389b1c27c4d030e129ab0ab688", "createTimestamp": "2020-03-30T15:05:27.880Z", "modifyTimestamp": "2020-03-30T15:05:39.871Z", "actor": "george.washington@c42se.com", "directoryId": ["0AB20OqRQS81NUk9PVA"], "source": "GoogleDrive", "url": "https://drive.google.com/a/c42se.com/file/d/1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk/view?usp=drivesdk", "shared": "TRUE", "sharedWith": [{"cloudUsername": "External (Public)"}], "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "0AB20OqRQS81NUk9PVA", "detectionSourceAlias": "C42SE GDrive2", "fileId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/plain", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_213", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.086Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_210", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.079Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_201", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.075Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_199", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.992Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_207", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.037Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_205", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.090Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_204", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.088Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_202", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.077Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_211", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.082Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_200", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.014Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_198", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.988Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_208", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.071Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_212", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.084Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_209", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.074Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_206", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.012Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_203", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.080Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_169", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.759Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-19.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 228687, "fileOwner": "Administrators", "md5Checksum": "9da3457f38edd0e046c933175f46ca24", "sha256Checksum": "1d59d2c941afc4edc177ca6ea4bff0a0ff85b30c3d36498a68c46c157e93ebe5", "createTimestamp": "2019-02-07T18:16:40.414Z", "modifyTimestamp": "2019-02-07T18:16:40.781Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_167", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:18.732Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2019-02-07T18:14:54.402Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_172", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:14.775Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:34:54.617Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_168", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.788Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2019-02-07T18:21:40.645Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_171", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:16.780Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2018.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 888674, "fileOwner": "Administrators", "md5Checksum": "dd0bc4b60d44899ec14fedb3ba6e4ad9", "sha256Checksum": "4855a7290e8c0cb70ce2f12a7bd08ed0238d10176c54b78f79c27e309a56eb10", "createTimestamp": "2019-02-07T18:20:12.547Z", "modifyTimestamp": "2019-02-07T18:20:12.985Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_170", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.727Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-20.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 222829, "fileOwner": "Administrators", "md5Checksum": "de85d81335b089f30c3397e1174781e1", "sha256Checksum": "e049fc0fd048a49a8d0a581cd221af288d2f5882d7b88a88b46611e2037113aa", "createTimestamp": "2020-03-30T14:35:19.754Z", "modifyTimestamp": "2020-03-30T14:35:19.817Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_674", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:46.083Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:36:45.974Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_673", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.910Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-30T14:36:32.692Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_672", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.848Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-30T14:36:32.676Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_3", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_1", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:07.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_0", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_2", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_864", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_862", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_860", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_853", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_844", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_826", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "MississippiCloud1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 3897, "fileOwner": "jennifer.vang", "md5Checksum": "d790364577802d43b28e38249a4f01ef", "sha256Checksum": "7e22b9c6c7a19380acd28d699f866a0ee417b57f25b3e4240b95a34951b35685", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_822", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_814", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_811", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_859", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:25.813Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_851", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dragon-pan-_7l2FS4FicM-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10326110, "fileOwner": "jennifer.vang", "md5Checksum": "3a6aad3c9dea5aa2b04a84343270d767", "sha256Checksum": "3e7d1339057b496fe8d395c9cdbd7737a2da76f8d0c850503d175d209b2bb3c9", "createTimestamp": "2020-02-12T12:46:12Z", "modifyTimestamp": "2020-02-12T12:46:12Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_843", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:38.395Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_838", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:44.923Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_837", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_817", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_815", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "paul-hanaoka-a104tlUezug-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2818933, "fileOwner": "jennifer.vang", "md5Checksum": "68cc9c9d063c95303fafbc4a9a8b2d97", "sha256Checksum": "170779a12338948bff1e88aea7fd0c03d90b1c66fcb297f6476b1a4ec0ea82d5", "createTimestamp": "2020-02-12T12:45:52Z", "modifyTimestamp": "2020-02-12T12:45:52Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_813", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_872", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_833", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_830", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_828", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_821", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_819", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_873", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_867", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_852", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:11.204Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_834", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2018-12-10T21:28:34Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_824", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_820", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_870", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_869", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-12T12:46:26Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_866", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_863", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_850", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_842", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:39.654Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_841", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_839", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_829", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_865", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_849", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_847", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_835", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_871", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_861", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_856", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-13T16:10:19.557Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_836", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:53.508Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_818", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:29.161Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_858", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:23.312Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_857", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_855", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_840", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_831", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_827", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_868", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_854", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_848", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_846", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_845", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_832", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:47.431Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_825", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_823", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_816", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_808", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_807", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-12T12:46:18Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_804", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-12T12:46:34Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_793", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_791", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_790", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_783", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_774", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_767", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_763", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:15.688Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_760", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-12T12:46:20Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_744", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_801", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_786", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_785", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (114 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10416961, "fileOwner": "jennifer.vang", "md5Checksum": "b816ee1bf58595d8e5cd7a923e3eb8c9", "sha256Checksum": "a66691b862c63895e55079bce5a3a76c0b4863a436953549a802a872fe6bf4a2", "createTimestamp": "2018-12-10T21:30:22Z", "modifyTimestamp": "2018-12-10T21:30:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_756", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_752", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_745", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_803", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_766", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_751", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_742", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_799", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_796", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-12T12:55:04Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_795", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_772", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_769", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:18.105Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_762", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-12T12:46:10Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_757", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_748", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_780", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_775", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_770", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_768", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_765", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_764", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_755", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_743", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_737", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Cooper DB Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662448, "fileOwner": "jennifer.vang", "md5Checksum": "5b0ed4af0e989bde0339bc19ad61a8c3", "sha256Checksum": "390ec485088c848de1a5f260e220fcc0653651291b66124b80b11c14f9e6ff65", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_735", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "JaleelCRM2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4497, "fileOwner": "jennifer.vang", "md5Checksum": "741ef1acf2071b0f60d8487677f68e16", "sha256Checksum": "bc5b8e0924de3b4b143ac35201b85393015172cb8e894c879cf14d409669cc21", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_806", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_802", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_798", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_794", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_792", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_788", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_771", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_749", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:42.528Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_739", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.06.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407551, "fileOwner": "jennifer.vang", "md5Checksum": "72d06b6a958228513904082c644e0902", "sha256Checksum": "2cc02f5b08f626c9390851edbac05829d154e640635e49b6fb64b7f2647ffc61", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_805", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:20.918Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_784", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_778", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "BlackHornetStoryboard.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 1893, "fileOwner": "jennifer.vang", "md5Checksum": "3732d8580df54e63269e397eeba3de7d", "sha256Checksum": "b11ebfa2c83029db1929a18a2dbaf47fe1abda8c7af72882dcc3339d901ec958", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_776", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_773", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_758", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_753", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_747", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_746", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_738", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_736", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "MississippiCloud3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2657, "fileOwner": "jennifer.vang", "md5Checksum": "2b732f159cdaff64fee6bf922f4e6901", "sha256Checksum": "5ae69936410f58eaf5f290d753ba1e59daee654ea7035c30d665dbb7e469febc", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_797", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_789", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_777", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-12T12:47:00Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_754", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_740", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_733", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_809", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_800", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_787", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (119 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 11786180, "fileOwner": "jennifer.vang", "md5Checksum": "5fa18acf4fc97eb4bf8632a60b956a6c", "sha256Checksum": "4228073c4aee56559d664c0f35c02d7bea17809dc9a4be7efa890ad7e49a81a5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_782", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_781", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "Longfellow North Campus Network Diagram.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 6733, "fileOwner": "jennifer.vang", "md5Checksum": "0df46da580a4acb02e9e509da7e2ec32", "sha256Checksum": "8605a5edb90daeef9047f99bbd676de3c51b59d19ff44eefe4b4d1f89674c24e", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_779", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_761", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_759", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_750", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:44.240Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_741", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:45.628Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_734", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941983451917189059_947621656901949516_346", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:15.756Z", "insertionTimestamp": "2020-03-30T13:15:24.556Z", "filePath": "C:/Users/darnell.waters/OneDrive - Code42/", "fileName": ".849C9593-D756-4E56-8D6E-42412F2A707B", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 63, "fileOwner": "darnell.waters", "md5Checksum": "e37ee15a01960b22e4ece7f055532215", "sha256Checksum": "002d0c0a9f80d3bb5df04547e533553d4046d008bb88807627801157276b535c", "createTimestamp": "2020-02-19T21:48:30.549Z", "modifyTimestamp": "2020-03-30T12:51:09.790Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.39", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.39", "fe80:0:0:0:1d77:dcdf:c593:1143%eth2", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "941983451917189059", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "OneDrive", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_731", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:03.757Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Inscents.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 32346, "fileOwner": "kathy.kane", "md5Checksum": "6ec589b2e49feebe91b29c447c34fd99", "sha256Checksum": "b9fd589c001b4e8d96d2238e42412f80e039456d91f42fefebdfd055ed56504a", "createTimestamp": "2020-03-30T12:17:14.785Z", "modifyTimestamp": "2020-03-30T12:17:18.098Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_730", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:08.695Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "kathy.kane", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:14:10.543Z", "modifyTimestamp": "2020-03-30T12:14:12.757Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.430Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T11:53:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_811", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.461Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_809", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.368Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T12:02:48Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/octet-stream", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.492Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947613460610701533_502", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:03.888Z", "insertionTimestamp": "2020-03-30T11:53:59.251Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "SAC_Book_SecurityAwarenessPlaybook.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 3125834, "fileOwner": "jordan.anderson", "md5Checksum": "af3a63b4bbe732f1b7f17694e1762de8", "sha256Checksum": "7c558f359788befa3700e3c901caeb738ebc2475803cc347bebf42a692ee8724", "createTimestamp": "2019-05-22T17:17:57.425Z", "modifyTimestamp": "2019-05-22T17:17:59.481Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612912599718109_334", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:00.980Z", "insertionTimestamp": "2020-03-30T11:48:34.115Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "N-SOS-022_TheGlobalCostOfInsecurity.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 562441, "fileOwner": "jordan.anderson", "md5Checksum": "9d5b6ced2937c1bac8231e259560f0d4", "sha256Checksum": "0b3660bccde1197d521ca10d532f5e978ca31e78552466842c8c74e0fe5012fa", "createTimestamp": "2019-05-22T17:18:05.266Z", "modifyTimestamp": "2019-05-22T17:18:06.653Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612023390241449_126", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:36:55.028Z", "insertionTimestamp": "2020-03-30T11:39:43.734Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "jordan.anderson", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T11:20:50.858Z", "modifyTimestamp": "2020-03-30T11:20:55.671Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Inbox (9) - jordan.anderson@c42se.com - Code42 SE Mail - Mozilla Firefox"], "tabUrl": "https://mail.google.com/mail/u/0/#inbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_266", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:08.054Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunJenkinsSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 503, "fileOwner": "kathy.kane", "md5Checksum": "31e9b26ca9caeafd44b1d81d7fd216c3", "sha256Checksum": "e0de2ec27a9bb5ba229cd38c47d3015ab20345a6a92a0b2e3e8276c2e104bfa7", "createTimestamp": "2020-04-01T11:49:19.390Z", "modifyTimestamp": "2020-04-01T11:49:21.102Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_274", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:27.325Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver.exe", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Executable", "fileSize": 8543232, "fileOwner": "kathy.kane", "md5Checksum": "8ee62a8925030966a240521561e13f5a", "sha256Checksum": "66cfa645f83fde41720beac7061a559fd57b6f5caa83d7918f44de0f4dd27845", "createTimestamp": "2020-04-01T11:47:08.616Z", "modifyTimestamp": "2020-04-01T11:47:11.721Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-msdownload", "mimeTypeByExtension": "application/x-dosexec", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_269", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:07.038Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunSingleSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 490, "fileOwner": "kathy.kane", "md5Checksum": "075169d962d428547131e8669343b64b", "sha256Checksum": "336372de237f7f355550fdf8e48294c24a931f57a244176f58379e14f78d6f01", "createTimestamp": "2020-04-01T11:49:24.618Z", "modifyTimestamp": "2020-04-01T11:49:26.509Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_272", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:23.245Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Uncategorized", "fileSize": 14713200, "fileOwner": "kathy.kane", "md5Checksum": "f8999bb031325631ec685aba3c3266f5", "sha256Checksum": "b91856fda0fc769d8781dac5592b3f776f16b45b82b23fd636d45646e7d5d1f5", "createTimestamp": "2020-04-01T11:47:22.050Z", "modifyTimestamp": "2020-04-01T11:47:23.711Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-mach-o", "mimeTypeByExtension": "application/octet-stream", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_12", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.019Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.328Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_10", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.298Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_13", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.057Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.332Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_11", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.983Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.324Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_410", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.752Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToDecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1137, "fileOwner": "kathy.kane", "md5Checksum": "22f1e7d589972ca5fad60c8519d20e54", "sha256Checksum": "91fd221bf07accb12fb54f8a24349442a70a6f1e2a784e02d7b54c8183805613", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.765Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_411", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.736Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToHexadecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1642, "fileOwner": "kathy.kane", "md5Checksum": "985232edbb7900aa3def0a349718265e", "sha256Checksum": "0a9745b02fff401f03afbf571f11465373edaa1f63a8cb6f6503f4a5768ef9a2", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.827Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_409", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:51.298Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "IntegerToRoman.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1149, "fileOwner": "kathy.kane", "md5Checksum": "c10dce754394e1d1af170a9be3fef3f4", "sha256Checksum": "0c1bdae526817ae624223a8d3231ba3e1b6e8f67708e2db7eda1150477e7414a", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.886Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_412", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:53.816Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "RomanToInteger.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1441, "fileOwner": "kathy.kane", "md5Checksum": "d92e8215a4b799c8f9a2dec10218ab01", "sha256Checksum": "e2cd78b8a1a258b114648240eeeef7bdec5e68e54713174e0a01c0a7bb72a46c", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.796Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_80", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.784Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_81", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.815Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_79", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.753Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.237Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_78", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.034Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.472Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_294", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.897Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:31.116Z", "modifyTimestamp": "2019-08-12T16:41:56.988Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_292", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.554Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:31.038Z", "modifyTimestamp": "2019-08-12T16:41:57.139Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_293", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.803Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:31.069Z", "modifyTimestamp": "2019-08-12T16:41:55.779Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_291", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.366Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:31.007Z", "modifyTimestamp": "2019-08-12T16:41:56.842Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_324", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:58.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "MSA - Lackawanna Touring Company.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Document", "fileSize": 382094, "fileOwner": "kathy.kane", "md5Checksum": "39e21b6e0a1d4902c98baa5e3aeaba19", "sha256Checksum": "854156252e3ca1024050b7c20e76b3ede6649a48a3980899ef04ab9df534abc5", "createTimestamp": "2020-04-01T10:43:57.354Z", "modifyTimestamp": "2020-04-01T10:44:00.510Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_323", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.879Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "LTC - DC Replacement Project Plan.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 13635, "fileOwner": "kathy.kane", "md5Checksum": "3ef51bbb881c915bba30a6796553c005", "sha256Checksum": "4c3d8223b02f4299c80c0590dddd4c206f00b89419753fd9301b8cc992aa5fe9", "createTimestamp": "2020-04-01T10:43:36.417Z", "modifyTimestamp": "2020-04-01T10:43:39.729Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_322", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Lackawanna Touring Company.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Spreadsheet", "fileSize": 32354, "fileOwner": "kathy.kane", "md5Checksum": "aab45b5dd52dccb21a0e7e18bff9229e", "sha256Checksum": "90fa1ba4dfd2624c66e13ed6de7e676fb3558d2e4dd424aa2bbb5740b65b31cf", "createTimestamp": "2020-04-01T10:43:44.916Z", "modifyTimestamp": "2020-04-01T10:43:48.385Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_687", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:04.665Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "ZOOOOOOMYBoi.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 22137371, "fileOwner": "darnell.waters", "md5Checksum": "124fa909c632f80b70f016eecf440fd3", "sha256Checksum": "043173fb09f1001dcad6934dfd988b6fe91f6f03982dcc92dfe0292a93a4e803", "createTimestamp": "2020-02-06T15:42:20Z", "modifyTimestamp": "2020-02-19T19:11:17.378Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_685", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:49:09.123Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "GotWings.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 12654813, "fileOwner": "darnell.waters", "md5Checksum": "84958f28d8e3f0af82a9143fa98edc92", "sha256Checksum": "771acf81676efa85688fed2b7b0850a75cf6857d5998e9eab7c4247a3a48314e", "createTimestamp": "2020-02-06T15:25:30Z", "modifyTimestamp": "2020-02-19T19:11:18.539Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_686", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:20.332Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "THEBOSS.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 28262513, "fileOwner": "darnell.waters", "md5Checksum": "62eda4aada3ee1c7b18ab10970636b54", "sha256Checksum": "15f9d5e9ef79a3d6755b6df9b8406f3d0adf4abbab07d2b7df5645f71530554f", "createTimestamp": "2020-02-06T15:22:40Z", "modifyTimestamp": "2020-02-19T19:11:19.568Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_183", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:46:19.896Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_182", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:45:57.608Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_82", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.948Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-devportal.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1447, "fileOwner": "sean.cassidy", "md5Checksum": "0beee3cec377487154903f2d213c37fe", "sha256Checksum": "5a810a00d365c563314808e7c7934e531f327277e00ca6267976f036a170d28c", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.658Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_86", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.986Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-redis.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2100, "fileOwner": "sean.cassidy", "md5Checksum": "a340a797bd8e0981bf9dc9f3b4cd6f0c", "sha256Checksum": "f4a2b19821e2c8f096b2e74663bb0d2664046edf6b9f5c4b736b860c55ec933a", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.736Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_90", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.003Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds-rbac.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2004, "fileOwner": "sean.cassidy", "md5Checksum": "3905c4678af557eb44841c4bb2525b80", "sha256Checksum": "784d7f9cc3d709b7e1e7dbbfaa9027a887263ff78398f4ef4a5e0b43e1e64173", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.829Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_81", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.965Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "admin-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1491, "fileOwner": "sean.cassidy", "md5Checksum": "f61050ab8def08a384bbd0bed47c8cd6", "sha256Checksum": "84242f6fefff710efc16971b44479f81881ccccd3e96bc07a35c29ae99a04178", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.626Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_87", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.966Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2680, "fileOwner": "sean.cassidy", "md5Checksum": "080fc77a1284b0439dc8218df43668a9", "sha256Checksum": "5ad11226c30229686464543324255046f4d5a89c19f1fb6fda674b44f6c9fce3", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.752Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_83", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.928Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-auth.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1203, "fileOwner": "sean.cassidy", "md5Checksum": "d9b52beb12fd195f8bf347c4ea95df62", "sha256Checksum": "c2b67cb056dc7e4fa82dac3d3b18091922619c02e2783247fcb2c068987944d6", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.673Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_85", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:28.983Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-ratelimit.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 371, "fileOwner": "sean.cassidy", "md5Checksum": "ee51e7c14f3bafb58ea317d2173c1b79", "sha256Checksum": "86fec44093ad5c8aee2dd98f4686eb0e9b8fc98d8e0e5a5e4b762b47fb30c372", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.720Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_84", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.007Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-license-key-secret.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 227, "fileOwner": "sean.cassidy", "md5Checksum": "fd89e26a07fa4f8503fd40259f6d43d5", "sha256Checksum": "7395defcf955595295ba8c3ce16890fc4ee987311b2c801b1fc6a31a03053307", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.689Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_91", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:24.418Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 136, "fileOwner": "sean.cassidy", "md5Checksum": "fe3a88fb7c4f3032ddc75a50844d42fd", "sha256Checksum": "240083c41b206ada276328f0988b28a140bfd09f3b884e80463557db69d29d18", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.845Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_89", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.923Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crd-delete.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1621, "fileOwner": "sean.cassidy", "md5Checksum": "41b4d9e96a10d80087088eb06e3d92bd", "sha256Checksum": "b4fcecdc5b9a440d976e27adaa99d48d5eeacbc9a8c98827b0ce4c3a43f4cf01", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.798Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_88", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.946Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "config.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 605, "fileOwner": "sean.cassidy", "md5Checksum": "8042961777d6ee44573224233a9687ea", "sha256Checksum": "089cd64bda07824df9b16a51d9f9b2c3c3dd835624c39c6fb39bab562b65f038", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.783Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947792142030207362_434", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:24:35.336Z", "insertionTimestamp": "2020-03-31T17:29:02.459Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/", "fileName": "configure.py", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 57602, "fileOwner": "sean.cassidy", "md5Checksum": "75a4c54c9421b296c0a63a044029fad5", "sha256Checksum": "8ab6290f42c53c940f08f4fbe520ebd5e72d1dc85683b17783e38b89280f1a41", "createTimestamp": "2020-03-07T17:41:27.411Z", "modifyTimestamp": "2020-03-31T17:24:07.424Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.4.0\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-python", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947791329686485442_62", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:18:33.189Z", "insertionTimestamp": "2020-03-31T17:20:56.912Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "your-marketing-plan-template.doc", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Document", "fileSize": 45568, "fileOwner": "Administrators", "md5Checksum": "6bb8604e540d3df44f18db72dfd5908f", "sha256Checksum": "4e121eed4819e5586930844475505da498f9ce424d3d43595a0d3473bfada2fc", "createTimestamp": "2019-02-07T16:23:05.662Z", "modifyTimestamp": "2019-02-07T16:23:06.908Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (54) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-tika-msoffice", "mimeTypeByExtension": "application/msword", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947790854009780610_771", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:02:39.749Z", "insertionTimestamp": "2020-03-31T17:16:14.843Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/HashMaker/", "fileName": "BlockAllocator.h", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "SourceCode", "fileCategoryByExtension": "SourceCode", "fileSize": 3549, "fileOwner": "sean.cassidy", "md5Checksum": "601f6f6fc877d60922b9c1012370232c", "sha256Checksum": "f57cae2718ffea77ddb86fb0f95b214651626b167712ae2d0f9306259a7a6907", "createTimestamp": "2020-03-19T01:38:00Z", "modifyTimestamp": "2020-03-19T03:43:46.973Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.3.1\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/x-csrc", "mimeTypeByExtension": "text/x-chdr", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947790235711338946_143", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:08:56.530Z", "insertionTimestamp": "2020-03-31T17:10:04.773Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "The Chiropractic Report Chapman Referral Letters.PDF", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 503962, "fileOwner": "Administrators", "md5Checksum": "9c0b34317626ab2b393d48e8f726569e", "sha256Checksum": "0536562c0e47848c6dcab72cade08eefeea5a0c67cb3c0b92f79d7b585522807", "createTimestamp": "2018-10-02T19:13:47.218Z", "modifyTimestamp": "2018-03-21T21:22:48.303Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (53) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_411", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:29.955Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T16:57:23.132Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_410", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.063Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T16:57:23.141Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_409", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.028Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T16:57:23.131Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_269", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.207Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_271", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.218Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_270", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.214Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_266", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.189Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_265", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.188Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_260", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.215Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_259", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.190Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_263", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.222Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_262", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.221Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_261", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.217Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_258", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.186Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_257", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.183Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_268", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.206Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_256", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.181Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_267", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.192Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_264", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.184Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_117", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.327Z", "insertionTimestamp": "2020-03-31T11:04:15.595Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T11:00:48.869Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_115", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:51.545Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T11:00:48.353Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_116", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.389Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T11:00:48.885Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "643502901225__8749df16-e136-4268-bc85-5323f8db2597", "eventType": "MODIFIED", "eventTimestamp": "2020-03-31T03:08:06.978Z", "insertionTimestamp": "2020-03-31T09:02:25.372Z", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 56653, "fileOwner": "kathy.kane@c42se.com", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:17:38Z", "modifyTimestamp": "2020-03-30T12:17:38Z", "actor": "kathy.kane@c42se.com", "directoryId": ["108056515629"], "source": "Box", "url": "https://code42a.box.com/s/sblis4r0zr5p0rbrr87fu3zml8svej58", "shared": "TRUE", "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "9981852168", "detectionSourceAlias": "C42 SE Box", "fileId": "643502901225", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} +{"eventId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk_2_9164694a-48e8-4c89-aed8-36d51d6338d4", "eventType": "CREATED", "eventTimestamp": "2020-03-30T15:29:52.894Z", "insertionTimestamp": "2020-03-31T00:01:48.913Z", "fileName": "9.29 Meeting Notes.txt", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "Document", "fileSize": 8089, "fileOwner": "george.washington@c42se.com", "md5Checksum": "86eb5a3c9d0ea6b6c37d3f988f42c718", "sha256Checksum": "4468e007b9b4a8050c10d29b3c9b38ea66d896389b1c27c4d030e129ab0ab688", "createTimestamp": "2020-03-30T15:05:27.880Z", "modifyTimestamp": "2020-03-30T15:05:39.871Z", "actor": "george.washington@c42se.com", "directoryId": ["0AB20OqRQS81NUk9PVA"], "source": "GoogleDrive", "url": "https://drive.google.com/a/c42se.com/file/d/1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk/view?usp=drivesdk", "shared": "TRUE", "sharedWith": [{"cloudUsername": "External (Public)"}], "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "0AB20OqRQS81NUk9PVA", "detectionSourceAlias": "C42SE GDrive2", "fileId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/plain", "mimeTypeMismatch": false} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_213", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.086Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_210", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.079Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_201", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.075Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_199", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.992Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_207", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.037Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_205", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.090Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_204", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.088Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_202", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.077Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_211", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.082Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_200", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.014Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_198", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.988Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_208", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.071Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_212", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.084Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_209", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.074Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_206", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.012Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_203", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.080Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_169", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.759Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-19.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 228687, "fileOwner": "Administrators", "md5Checksum": "9da3457f38edd0e046c933175f46ca24", "sha256Checksum": "1d59d2c941afc4edc177ca6ea4bff0a0ff85b30c3d36498a68c46c157e93ebe5", "createTimestamp": "2019-02-07T18:16:40.414Z", "modifyTimestamp": "2019-02-07T18:16:40.781Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_167", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:18.732Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2019-02-07T18:14:54.402Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_172", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:14.775Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:34:54.617Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_168", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.788Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2019-02-07T18:21:40.645Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_171", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:16.780Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2018.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 888674, "fileOwner": "Administrators", "md5Checksum": "dd0bc4b60d44899ec14fedb3ba6e4ad9", "sha256Checksum": "4855a7290e8c0cb70ce2f12a7bd08ed0238d10176c54b78f79c27e309a56eb10", "createTimestamp": "2019-02-07T18:20:12.547Z", "modifyTimestamp": "2019-02-07T18:20:12.985Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_170", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.727Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-20.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 222829, "fileOwner": "Administrators", "md5Checksum": "de85d81335b089f30c3397e1174781e1", "sha256Checksum": "e049fc0fd048a49a8d0a581cd221af288d2f5882d7b88a88b46611e2037113aa", "createTimestamp": "2020-03-30T14:35:19.754Z", "modifyTimestamp": "2020-03-30T14:35:19.817Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_674", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:46.083Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:36:45.974Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_673", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.910Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-30T14:36:32.692Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_672", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.848Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-30T14:36:32.676Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_3", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_1", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:07.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_0", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_2", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_864", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_862", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_860", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_853", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_844", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_826", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "MississippiCloud1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 3897, "fileOwner": "jennifer.vang", "md5Checksum": "d790364577802d43b28e38249a4f01ef", "sha256Checksum": "7e22b9c6c7a19380acd28d699f866a0ee417b57f25b3e4240b95a34951b35685", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_822", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_814", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_811", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_859", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:25.813Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_851", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dragon-pan-_7l2FS4FicM-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10326110, "fileOwner": "jennifer.vang", "md5Checksum": "3a6aad3c9dea5aa2b04a84343270d767", "sha256Checksum": "3e7d1339057b496fe8d395c9cdbd7737a2da76f8d0c850503d175d209b2bb3c9", "createTimestamp": "2020-02-12T12:46:12Z", "modifyTimestamp": "2020-02-12T12:46:12Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_843", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:38.395Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_838", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:44.923Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_837", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_817", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_815", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "paul-hanaoka-a104tlUezug-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2818933, "fileOwner": "jennifer.vang", "md5Checksum": "68cc9c9d063c95303fafbc4a9a8b2d97", "sha256Checksum": "170779a12338948bff1e88aea7fd0c03d90b1c66fcb297f6476b1a4ec0ea82d5", "createTimestamp": "2020-02-12T12:45:52Z", "modifyTimestamp": "2020-02-12T12:45:52Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_813", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_872", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_833", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_830", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_828", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_821", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_819", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_873", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_867", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_852", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:11.204Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_834", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2018-12-10T21:28:34Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_824", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_820", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_870", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_869", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-12T12:46:26Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_866", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_863", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_850", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_842", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:39.654Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_841", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_839", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_829", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_865", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_849", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_847", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_835", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_871", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_861", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_856", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-13T16:10:19.557Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_836", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:53.508Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_818", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:29.161Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_858", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:23.312Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_857", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_855", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_840", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_831", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_827", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_868", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_854", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_848", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_846", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_845", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_832", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:47.431Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_825", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_823", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_816", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_808", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_807", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-12T12:46:18Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_804", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-12T12:46:34Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_793", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_791", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_790", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_783", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_774", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_767", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_763", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:15.688Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_760", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-12T12:46:20Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_744", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_801", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_786", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_785", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (114 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10416961, "fileOwner": "jennifer.vang", "md5Checksum": "b816ee1bf58595d8e5cd7a923e3eb8c9", "sha256Checksum": "a66691b862c63895e55079bce5a3a76c0b4863a436953549a802a872fe6bf4a2", "createTimestamp": "2018-12-10T21:30:22Z", "modifyTimestamp": "2018-12-10T21:30:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_756", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_752", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_745", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_803", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_766", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_751", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_742", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_799", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_796", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-12T12:55:04Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_795", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_772", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_769", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:18.105Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_762", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-12T12:46:10Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_757", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_748", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_780", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_775", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_770", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_768", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_765", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_764", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_755", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_743", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_737", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Cooper DB Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662448, "fileOwner": "jennifer.vang", "md5Checksum": "5b0ed4af0e989bde0339bc19ad61a8c3", "sha256Checksum": "390ec485088c848de1a5f260e220fcc0653651291b66124b80b11c14f9e6ff65", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_735", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "JaleelCRM2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4497, "fileOwner": "jennifer.vang", "md5Checksum": "741ef1acf2071b0f60d8487677f68e16", "sha256Checksum": "bc5b8e0924de3b4b143ac35201b85393015172cb8e894c879cf14d409669cc21", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_806", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_802", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_798", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_794", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_792", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_788", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_771", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_749", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:42.528Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_739", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.06.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407551, "fileOwner": "jennifer.vang", "md5Checksum": "72d06b6a958228513904082c644e0902", "sha256Checksum": "2cc02f5b08f626c9390851edbac05829d154e640635e49b6fb64b7f2647ffc61", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_805", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:20.918Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_784", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_778", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "BlackHornetStoryboard.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 1893, "fileOwner": "jennifer.vang", "md5Checksum": "3732d8580df54e63269e397eeba3de7d", "sha256Checksum": "b11ebfa2c83029db1929a18a2dbaf47fe1abda8c7af72882dcc3339d901ec958", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_776", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_773", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_758", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_753", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_747", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_746", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_738", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_736", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "MississippiCloud3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2657, "fileOwner": "jennifer.vang", "md5Checksum": "2b732f159cdaff64fee6bf922f4e6901", "sha256Checksum": "5ae69936410f58eaf5f290d753ba1e59daee654ea7035c30d665dbb7e469febc", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_797", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_789", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_777", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-12T12:47:00Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_754", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_740", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_733", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_809", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_800", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_787", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (119 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 11786180, "fileOwner": "jennifer.vang", "md5Checksum": "5fa18acf4fc97eb4bf8632a60b956a6c", "sha256Checksum": "4228073c4aee56559d664c0f35c02d7bea17809dc9a4be7efa890ad7e49a81a5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_782", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_781", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "Longfellow North Campus Network Diagram.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 6733, "fileOwner": "jennifer.vang", "md5Checksum": "0df46da580a4acb02e9e509da7e2ec32", "sha256Checksum": "8605a5edb90daeef9047f99bbd676de3c51b59d19ff44eefe4b4d1f89674c24e", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_779", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_761", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_759", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_750", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:44.240Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_741", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:45.628Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_734", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941983451917189059_947621656901949516_346", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:15.756Z", "insertionTimestamp": "2020-03-30T13:15:24.556Z", "filePath": "C:/Users/darnell.waters/OneDrive - Code42/", "fileName": ".849C9593-D756-4E56-8D6E-42412F2A707B", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 63, "fileOwner": "darnell.waters", "md5Checksum": "e37ee15a01960b22e4ece7f055532215", "sha256Checksum": "002d0c0a9f80d3bb5df04547e533553d4046d008bb88807627801157276b535c", "createTimestamp": "2020-02-19T21:48:30.549Z", "modifyTimestamp": "2020-03-30T12:51:09.790Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.39", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.39", "fe80:0:0:0:1d77:dcdf:c593:1143%eth2", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "941983451917189059", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "OneDrive", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_731", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:03.757Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Inscents.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 32346, "fileOwner": "kathy.kane", "md5Checksum": "6ec589b2e49feebe91b29c447c34fd99", "sha256Checksum": "b9fd589c001b4e8d96d2238e42412f80e039456d91f42fefebdfd055ed56504a", "createTimestamp": "2020-03-30T12:17:14.785Z", "modifyTimestamp": "2020-03-30T12:17:18.098Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_730", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:08.695Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "kathy.kane", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:14:10.543Z", "modifyTimestamp": "2020-03-30T12:14:12.757Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.430Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T11:53:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_811", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.461Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_809", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.368Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T12:02:48Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/octet-stream", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.492Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947613460610701533_502", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:03.888Z", "insertionTimestamp": "2020-03-30T11:53:59.251Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "SAC_Book_SecurityAwarenessPlaybook.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 3125834, "fileOwner": "jordan.anderson", "md5Checksum": "af3a63b4bbe732f1b7f17694e1762de8", "sha256Checksum": "7c558f359788befa3700e3c901caeb738ebc2475803cc347bebf42a692ee8724", "createTimestamp": "2019-05-22T17:17:57.425Z", "modifyTimestamp": "2019-05-22T17:17:59.481Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612912599718109_334", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:00.980Z", "insertionTimestamp": "2020-03-30T11:48:34.115Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "N-SOS-022_TheGlobalCostOfInsecurity.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 562441, "fileOwner": "jordan.anderson", "md5Checksum": "9d5b6ced2937c1bac8231e259560f0d4", "sha256Checksum": "0b3660bccde1197d521ca10d532f5e978ca31e78552466842c8c74e0fe5012fa", "createTimestamp": "2019-05-22T17:18:05.266Z", "modifyTimestamp": "2019-05-22T17:18:06.653Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} +{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612023390241449_126", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:36:55.028Z", "insertionTimestamp": "2020-03-30T11:39:43.734Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "jordan.anderson", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T11:20:50.858Z", "modifyTimestamp": "2020-03-30T11:20:55.671Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Inbox (9) - jordan.anderson@c42se.com - Code42 SE Mail - Mozilla Firefox"], "tabUrl": "https://mail.google.com/mail/u/0/#inbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} diff --git a/src/code42cli/securitydata/arguments/__init__.py b/tests/cmds/__init__.py similarity index 100% rename from src/code42cli/securitydata/arguments/__init__.py rename to tests/cmds/__init__.py diff --git a/src/code42cli/securitydata/subcommands/__init__.py b/tests/cmds/securitydata/__init__.py similarity index 100% rename from src/code42cli/securitydata/subcommands/__init__.py rename to tests/cmds/securitydata/__init__.py diff --git a/tests/cmds/securitydata/conftest.py b/tests/cmds/securitydata/conftest.py new file mode 100644 index 000000000..c6e617f63 --- /dev/null +++ b/tests/cmds/securitydata/conftest.py @@ -0,0 +1,78 @@ +import json as json_module +import pytest +from datetime import datetime, timedelta + +SECURITYDATA_NAMESPACE = "code42cli.cmds.securitydata" + + +def get_filter_value_from_json(json, filter_index): + return json_module.loads(str(json))["filters"][filter_index]["value"] + + +def parse_date_from_filter_value(json, filter_index): + date_str = get_filter_value_from_json(json, filter_index) + return convert_str_to_date(date_str) + + +def convert_str_to_date(date_str): + return datetime.strptime(date_str, u"%Y-%m-%dT%H:%M:%S.%fZ") + + +def get_test_date(days_ago): + now = datetime.utcnow() + return now - timedelta(days=days_ago) + + +def get_test_date_str(days_ago): + return get_test_date(days_ago).strftime("%Y-%m-%d") + + +begin_date_str = get_test_date_str(days_ago=89) +begin_date_str_with_time = "{0} 3:12:33".format(begin_date_str) +end_date_str = get_test_date_str(days_ago=10) +end_date_str_with_time = "{0} 11:22:43".format(end_date_str) +begin_date_list = [get_test_date_str(days_ago=89)] +begin_date_list_with_time = [get_test_date_str(days_ago=89), "3:12:33"] +end_date_list = [get_test_date_str(days_ago=10)] +end_date_list_with_time = [get_test_date_str(days_ago=10), "11:22:43"] + + +@pytest.fixture(autouse=True) +def sqlite_connection(mocker): + return mocker.patch("sqlite3.connect") + + +ACCEPTABLE_ARGS = [ + "-t", + "SharedToDomain", + "ApplicationRead", + "CloudStorage", + "RemovableMedia", + "IsPublic", + "-f", + "JSON", + "-d", + "-b", + "600", + "-e", + "2020-02-02", + "--c42username", + "test.testerson", + "--actor", + "test.testerson", + "--md5", + "098f6bcd4621d373cade4e832627b4f6", + "--sha256", + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "--source", + "Gmail", + "--filename", + "file.txt", + "--filepath", + "/path/to/file.txt", + "--processOwner", + "test.testerson", + "--tabURL", + "https://example.com", + "--include-non-exposure", +] diff --git a/tests/securitydata/test_cursor_store.py b/tests/cmds/securitydata/test_cursor_store.py similarity index 94% rename from tests/securitydata/test_cursor_store.py rename to tests/cmds/securitydata/test_cursor_store.py index 37f2732a4..5ceddbaeb 100644 --- a/tests/securitydata/test_cursor_store.py +++ b/tests/cmds/securitydata/test_cursor_store.py @@ -1,8 +1,7 @@ -from os import path - from c42eventextractor.extractors import INSERTION_TIMESTAMP_FIELD_NAME +from os import path -from code42cli.securitydata.cursor_store import BaseCursorStore, FileEventCursorStore +from code42cli.cmds.shared.cursor_store import BaseCursorStore, FileEventCursorStore class TestBaseCursorStore(object): @@ -28,7 +27,7 @@ class TestFileEventCursorStore(object): def test_init_when_called_twice_with_different_profile_names_creates_two_rows( self, mocker, sqlite_connection ): - mock = mocker.patch("code42cli.securitydata.cursor_store.FileEventCursorStore._row_exists") + mock = mocker.patch("code42cli.cmds.shared.cursor_store.FileEventCursorStore._row_exists") mock.return_value = False spy = mocker.spy(FileEventCursorStore, "_insert_new_row") FileEventCursorStore("Profile A", self.MOCK_TEST_DB_NAME) diff --git a/tests/securitydata/test_date_helper.py b/tests/cmds/securitydata/test_date_helper.py similarity index 95% rename from tests/securitydata/test_date_helper.py rename to tests/cmds/securitydata/test_date_helper.py index cb458ed2d..00f368bb2 100644 --- a/tests/securitydata/test_date_helper.py +++ b/tests/cmds/securitydata/test_date_helper.py @@ -1,13 +1,14 @@ import pytest -from code42cli.securitydata.date_helper import create_event_timestamp_filter +from code42cli.cmds.securitydata.date_helper import create_event_timestamp_filter from .conftest import ( begin_date_list, begin_date_list_with_time, end_date_list, end_date_list_with_time, + get_filter_value_from_json, + get_test_date_str, ) -from ..conftest import get_filter_value_from_json, get_test_date_str def test_create_event_timestamp_filter_when_given_nothing_returns_none(): diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py new file mode 100644 index 000000000..4320620dc --- /dev/null +++ b/tests/cmds/securitydata/test_extraction.py @@ -0,0 +1,554 @@ +import pytest +from py42.sdk import SDKClient +from py42.sdk.queries.fileevents.filters import * + +import code42cli.cmds.securitydata.extraction as extraction_module +from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions +from .conftest import ( + SECURITYDATA_NAMESPACE, + begin_date_str, + get_filter_value_from_json, + get_test_date_str, +) + + +@pytest.fixture +def sdk(mocker): + return mocker.MagicMock(spec=SDKClient) + + +@pytest.fixture() +def mock_42(mocker): + return mocker.patch("py42.sdk.from_local_account") + + +@pytest.fixture +def logger(mocker): + mock = mocker.MagicMock() + mock.info = mocker.MagicMock() + return mock + + +@pytest.fixture(autouse=True) +def error_logger(mocker): + return mocker.patch("{0}.extraction.get_error_logger".format(SECURITYDATA_NAMESPACE)) + + +@pytest.fixture +def extractor(mocker): + mock = mocker.MagicMock() + mock.extract_advanced = mocker.patch( + "c42eventextractor.extractors.FileEventExtractor.extract_advanced" + ) + mock.extract = mocker.patch("c42eventextractor.extractors.FileEventExtractor.extract") + return mock + + +@pytest.fixture +def namespace_with_begin(namespace): + namespace.begin = begin_date_str + return namespace + + +def filter_term_is_in_call_args(extractor, term): + arg_filters = extractor.extract.call_args[0] + for f in arg_filters: + if term in str(f): + return True + return False + + +def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( + sdk, profile, logger, namespace, extractor +): + namespace.advanced_query = "some complex json" + extraction_module.extract(sdk, profile, logger, namespace) + extractor.extract_advanced.assert_called_once_with("some complex json") + assert extractor.extract.call_count == 0 + + +def test_extract_when_is_advanced_query_and_has_begin_date_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.begin = "begin date" + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_end_date_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.end = "end date" + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_exposure_types_exits( + sdk, profile, logger, namespace +): + namespace.advanced_query = "some complex json" + namespace.type = [ExposureTypeOptions.SHARED_TO_DOMAIN] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_username_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.c42username = ["Someone"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_actor_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.actor = ["Someone"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_md5_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.md5 = ["098f6bcd4621d373cade4e832627b4f6"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_sha256_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.sha256 = ["9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_source_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.source = ["Gmail"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_filename_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.filename = ["test.out"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_filepath_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.filepath = ["path/to/file"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_process_owner_exits( + sdk, profile, logger, namespace +): + namespace.advanced_query = "some complex json" + namespace.processOwner = ["someone"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_tab_url_exits(sdk, profile, logger, namespace): + namespace.advanced_query = "some complex json" + namespace.tabURL = ["https://www.example.com"] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_incremental_mode_exits( + sdk, profile, logger, namespace +): + namespace.advanced_query = "some complex json" + namespace.incremental = True + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_include_non_exposure_exits( + sdk, profile, logger, namespace +): + namespace.advanced_query = "some complex json" + namespace.include_non_exposure = True + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_include_non_exposure_is_false_does_not_exit( + sdk, profile, logger, namespace +): + namespace.include_non_exposure = False + namespace.advanced_query = "some complex json" + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( + sdk, profile, logger, namespace +): + namespace.advanced_query = "some complex json" + namespace.is_incremental = False + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_is_not_advanced_query_uses_only_extract_method( + sdk, profile, logger, extractor, namespace_with_begin +): + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert extractor.extract.call_count == 1 + assert extractor.extract_raw.call_count == 0 + + +def test_extract_when_not_given_begin_or_advanced_causes_exit( + sdk, profile, logger, extractor, namespace +): + namespace.begin = None + namespace.advanced_query = None + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_given_begin_date_uses_expected_query( + sdk, profile, logger, namespace, extractor +): + namespace.begin = get_test_date_str(days_ago=89) + extraction_module.extract(sdk, profile, logger, namespace) + actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) + expected = "{0}T00:00:00.000Z".format(namespace.begin) + assert actual == expected + + +def test_extract_when_given_begin_date_and_time_uses_expected_query( + sdk, profile, logger, namespace, extractor +): + date = get_test_date_str(days_ago=89) + time = "15:33:02" + namespace.begin = get_test_date_str(days_ago=89) + " " + time + extraction_module.extract(sdk, profile, logger, namespace) + actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) + expected = "{0}T{1}.000Z".format(date, time) + assert actual == expected + + +def test_extract_when_given_end_date_uses_expected_query( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.end = get_test_date_str(days_ago=10) + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) + expected = "{0}T23:59:59.999Z".format(namespace_with_begin.end) + assert actual == expected + + +def test_extract_when_given_end_date_and_time_uses_expected_query( + sdk, profile, logger, namespace_with_begin, extractor +): + date = get_test_date_str(days_ago=10) + time = "12:00:11" + namespace_with_begin.end = date + " " + time + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) + expected = "{0}T{1}.000Z".format(date, time) + assert actual == expected + + +def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( + sdk, profile, logger, namespace, extractor +): + end_date = get_test_date_str(days_ago=55) + end_time = "13:44:44" + namespace.begin = get_test_date_str(days_ago=89) + namespace.end = end_date + " " + end_time + extraction_module.extract(sdk, profile, logger, namespace) + + actual_begin_timestamp = get_filter_value_from_json( + extractor.extract.call_args[0][0], filter_index=0 + ) + actual_end_timestamp = get_filter_value_from_json( + extractor.extract.call_args[0][0], filter_index=1 + ) + expected_begin_timestamp = "{0}T00:00:00.000Z".format(namespace.begin) + expected_end_timestamp = "{0}T{1}.000Z".format(end_date, end_time) + + assert actual_begin_timestamp == expected_begin_timestamp + assert actual_end_timestamp == expected_end_timestamp + + +def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( + sdk, profile, logger, namespace, extractor +): + namespace.incremental = False + date = get_test_date_str(days_ago=91) + " 12:51:00" + namespace.begin = date + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_end_date_is_before_begin_date_causes_exit( + sdk, profile, logger, namespace, extractor +): + namespace.begin = get_test_date_str(days_ago=5) + namespace.end = get_test_date_str(days_ago=6) + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + mocker, sdk, profile, logger, namespace, extractor +): + namespace.begin = "2019-01-01" + namespace.incremental = True + mock_checkpoint = mocker.patch( + "code42cli.cmds.shared.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" + ) + mock_checkpoint.return_value = 22624624 + extraction_module.extract(sdk, profile, logger, namespace) + assert not filter_term_is_in_call_args(extractor, EventTimestamp._term) + + +def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( + mocker, sdk, profile, logger, namespace, extractor +): + namespace.begin = get_test_date_str(days_ago=1) + namespace.incremental = False + mock_checkpoint = mocker.patch( + "code42cli.cmds.shared.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" + ) + mock_checkpoint.return_value = 22624624 + extraction_module.extract(sdk, profile, logger, namespace) + + actual_ts = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) + expected_ts = "{0}T00:00:00.000Z".format(namespace.begin) + assert actual_ts == expected_ts + assert filter_term_is_in_call_args(extractor, EventTimestamp._term) + + +def test_when_not_given_begin_date_and_is_incremental_but_no_stored_checkpoint_exists_causes_exit( + mocker, sdk, profile, logger, namespace, extractor +): + namespace.begin = None + namespace.is_incremental = True + mock_checkpoint = mocker.patch( + "code42cli.cmds.shared.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" + ) + mock_checkpoint.return_value = None + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_given_invalid_exposure_type_causes_exit( + sdk, profile, logger, namespace, extractor +): + namespace.type = [ + ExposureTypeOptions.APPLICATION_READ, + "SomethingElseThatIsNotSupported", + ExposureTypeOptions.IS_PUBLIC, + ] + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_given_username_uses_username_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.c42username = ["test.testerson@example.com"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + DeviceUsername.is_in(namespace_with_begin.c42username) + ) + + +def test_extract_when_given_actor_uses_actor_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.actor = ["test.testerson"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(Actor.is_in(namespace_with_begin.actor)) + + +def test_extract_when_given_md5_uses_md5_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.md5 = ["098f6bcd4621d373cade4e832627b4f6"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(MD5.is_in(namespace_with_begin.md5)) + + +def test_extract_when_given_sha256_uses_sha256_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.sha256 = [ + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(SHA256.is_in(namespace_with_begin.sha256)) + + +def test_extract_when_given_source_uses_source_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.source = ["Gmail", "Yahoo"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(Source.is_in(namespace_with_begin.source)) + + +def test_extract_when_given_filename_uses_filename_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.filename = ["file.txt", "txt.file"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + FileName.is_in(namespace_with_begin.filename) + ) + + +def test_extract_when_given_filepath_uses_filepath_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.filepath = ["/path/to/file.txt", "path2"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + FilePath.is_in(namespace_with_begin.filepath) + ) + + +def test_extract_when_given_process_owner_uses_process_owner_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.processOwner = ["test.testerson", "another"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + ProcessOwner.is_in(namespace_with_begin.processOwner) + ) + + +def test_extract_when_given_tab_url_uses_process_tab_url_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.tabURL = ["https://www.example.com"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(TabURL.is_in(namespace_with_begin.tabURL)) + + +def test_extract_when_given_exposure_types_uses_exposure_type_is_in_filter( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.type = ["ApplicationRead", "RemovableMedia", "CloudStorage"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + ExposureType.is_in(namespace_with_begin.type) + ) + + +def test_extract_when_given_include_non_exposure_does_not_include_exposure_type_exists( + mocker, sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.include_non_exposure = True + ExposureType.exists = mocker.MagicMock() + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert not ExposureType.exists.call_count + + +def test_extract_when_not_given_include_non_exposure_includes_exposure_type_exists( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.include_non_exposure = False + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str(ExposureType.exists()) + + +def test_extract_when_given_multiple_search_args_uses_expected_filters( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.filepath = ["/path/to/file.txt"] + namespace_with_begin.processOwner = ["test.testerson", "flag.flagerson"] + namespace_with_begin.tabURL = ["https://www.example.com"] + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert str(extractor.extract.call_args[0][1]) == str( + FilePath.is_in(namespace_with_begin.filepath) + ) + assert str(extractor.extract.call_args[0][2]) == str( + ProcessOwner.is_in(namespace_with_begin.processOwner) + ) + assert str(extractor.extract.call_args[0][3]) == str(TabURL.is_in(namespace_with_begin.tabURL)) + + +def test_extract_when_given_include_non_exposure_and_exposure_types_causes_exit( + sdk, profile, logger, namespace_with_begin, extractor +): + namespace_with_begin.type = ["ApplicationRead", "RemovableMedia", "CloudStorage"] + namespace_with_begin.include_non_exposure = True + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + + +def test_extract_when_creating_sdk_throws_causes_exit( + sdk, profile, logger, extractor, namespace, mock_42 +): + def side_effect(): + raise Exception() + + mock_42.side_effect = side_effect + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, namespace) + + +def test_extract_when_global_variable_is_true_and_is_interactive_prints_error( + mocker, sdk, profile, logger, namespace_with_begin, extractor +): + mock_error_printer = mocker.patch("code42cli.cmds.securitydata.extraction.print_error") + mock_is_interactive_function = mocker.patch( + "code42cli.cmds.securitydata.extraction.is_interactive" + ) + mock_is_interactive_function.return_value = True + extraction_module._EXCEPTIONS_OCCURRED = True + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert mock_error_printer.call_count + + +def test_extract_when_global_variable_is_true_and_not_is_interactive_does_not_print_error( + mocker, sdk, profile, logger, namespace_with_begin, extractor +): + mock_error_printer = mocker.patch("code42cli.cmds.securitydata.extraction.print_error") + mock_is_interactive_function = mocker.patch( + "code42cli.cmds.securitydata.extraction.is_interactive" + ) + mock_is_interactive_function.return_value = False + extraction_module._EXCEPTIONS_OCCURRED = True + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert not mock_error_printer.call_count + + +def test_extract_when_global_variable_is_false_and_is_interactive_does_not_print_error( + mocker, sdk, profile, logger, namespace_with_begin, extractor +): + mock_error_printer = mocker.patch("code42cli.cmds.securitydata.extraction.print_error") + mock_is_interactive_function = mocker.patch( + "code42cli.cmds.securitydata.extraction.is_interactive" + ) + mock_is_interactive_function.return_value = True + extraction_module._EXCEPTIONS_OCCURRED = False + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert not mock_error_printer.call_count + + +def test_when_sdk_raises_exception_global_variable_gets_set( + mocker, sdk, profile, logger, namespace_with_begin, mock_42 +): + extraction_module._EXCEPTIONS_OCCURRED = False + mock_sdk = mocker.MagicMock() + + # For ease + mock = mocker.patch("code42cli.cmds.securitydata.extraction.is_interactive") + mock.return_value = False + + def sdk_side_effect(self, *args): + raise Exception() + + mock_sdk.security.search_file_events.side_effect = sdk_side_effect + mock_42.return_value = mock_sdk + + mocker.patch( + "c42eventextractor.extractors.FileEventExtractor._verify_compatibility_of_filter_groups" + ) + + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert extraction_module._EXCEPTIONS_OCCURRED diff --git a/tests/securitydata/test_logger_factory.py b/tests/cmds/securitydata/test_logger_factory.py similarity index 99% rename from tests/securitydata/test_logger_factory.py rename to tests/cmds/securitydata/test_logger_factory.py index 97ec839c4..6f9ebdea5 100644 --- a/tests/securitydata/test_logger_factory.py +++ b/tests/cmds/securitydata/test_logger_factory.py @@ -1,14 +1,13 @@ import logging -from logging.handlers import RotatingFileHandler - import pytest from c42eventextractor.logging.formatters import ( FileEventDictToCEFFormatter, FileEventDictToJSONFormatter, FileEventDictToRawJSONFormatter, ) +from logging.handlers import RotatingFileHandler -import code42cli.securitydata.logger_factory as factory +import code42cli.cmds.securitydata.logger_factory as factory @pytest.fixture diff --git a/tests/cmds/securitydata/test_main.py b/tests/cmds/securitydata/test_main.py new file mode 100644 index 000000000..813e0f65c --- /dev/null +++ b/tests/cmds/securitydata/test_main.py @@ -0,0 +1,34 @@ +import pytest + +import code42cli.cmds.securitydata.main as main + + +@pytest.fixture +def mock_logger_factory(mocker): + return mocker.patch("code42cli.cmds.securitydata.main.logger_factory") + + +@pytest.fixture +def mock_extract(mocker): + return mocker.patch("code42cli.cmds.securitydata.main.extract") + + +def test_print_out(sdk, profile, namespace, mocker, mock_logger_factory, mock_extract): + logger = mocker.MagicMock() + mock_logger_factory.get_logger_for_stdout.return_value = logger + main.print_out(sdk, profile, namespace) + mock_extract.assert_called_with(sdk, profile, logger, namespace) + + +def test_write_to(sdk, profile, namespace, mocker, mock_logger_factory, mock_extract): + logger = mocker.MagicMock() + mock_logger_factory.get_logger_for_file.return_value = logger + main.write_to(sdk, profile, namespace) + mock_extract.assert_called_with(sdk, profile, logger, namespace) + + +def test_send_to(sdk, profile, namespace, mocker, mock_logger_factory, mock_extract): + logger = mocker.MagicMock() + mock_logger_factory.get_logger_for_server.return_value = logger + main.send_to(sdk, profile, namespace) + mock_extract.assert_called_with(sdk, profile, logger, namespace) diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py new file mode 100644 index 000000000..24e080f5b --- /dev/null +++ b/tests/cmds/test_profile.py @@ -0,0 +1,168 @@ +import pytest + +import code42cli.cmds.profile as profilecmd +from ..conftest import create_mock_profile + + +@pytest.fixture +def user_agreement(mocker): + mock = mocker.patch("code42cli.cmds.profile.does_user_agree") + mock.return_value = True + return mocker + + +@pytest.fixture +def user_disagreement(mocker): + mock = mocker.patch("code42cli.cmds.profile.does_user_agree") + mock.return_value = False + return mocker + + +@pytest.fixture +def mock_cliprofile_namespace(mocker): + return mocker.patch("code42cli.cmds.profile.cliprofile") + + +@pytest.fixture(autouse=True) +def mock_getpass(mocker): + mock = mocker.patch("code42cli.cmds.profile.getpass") + mock.return_value = "newpassword" + + +@pytest.fixture +def mock_verify(mocker): + return mocker.patch("code42cli.cmds.profile.validate_connection") + + +def test_show_profile_outputs_profile_info(capsys, mock_cliprofile_namespace, profile): + profile.name = "testname" + profile.authority_url = "example.com" + profile.username = "foo" + profile.disable_ssl_errors = True + mock_cliprofile_namespace.get_profile.return_value = profile + profilecmd.show_profile(profile) + capture = capsys.readouterr() + assert "testname" in capture.out + assert "example.com" in capture.out + assert "foo" in capture.out + assert "A password is set" in capture.out + + +def test_show_profile_when_password_set_outputs_password_note( + capsys, mock_cliprofile_namespace, profile +): + mock_cliprofile_namespace.get_profile.return_value = profile + mock_cliprofile_namespace.get_stored_password.return_value = None + profilecmd.show_profile(profile) + capture = capsys.readouterr() + assert "A password is set" not in capture.out + + +def test_create_profile_if_profile_exists_exits(capsys, mock_cliprofile_namespace): + mock_cliprofile_namespace.profile_exists.return_value = True + success = True + try: + profilecmd.create_profile("foo", "bar", "baz", True) + except SystemExit: + success = True + capture = capsys.readouterr() + assert "already exists" in capture.out + assert success + + +def test_create_profile_if_user_sets_password_is_created( + user_agreement, mock_verify, mock_cliprofile_namespace +): + mock_cliprofile_namespace.profile_exists.return_value = False + profilecmd.create_profile("foo", "bar", "baz", True) + mock_cliprofile_namespace.create_profile.assert_called_once_with("foo", "bar", "baz", True) + + +def test_create_profile_if_user_does_not_set_password_is_created( + user_disagreement, mock_verify, mock_cliprofile_namespace +): + mock_cliprofile_namespace.profile_exists.return_value = False + profilecmd.create_profile("foo", "bar", "baz", True) + mock_cliprofile_namespace.create_profile.assert_called_once_with("foo", "bar", "baz", True) + + +def test_create_profile_if_user_does_not_set_password_does_not_save_password( + user_disagreement, mock_verify, mock_cliprofile_namespace +): + mock_cliprofile_namespace.profile_exists.return_value = False + profilecmd.create_profile("foo", "bar", "baz", True) + assert not mock_cliprofile_namespace.set_password.call_count + + +def test_create_profile_if_credentials_invalid_password_not_saved( + user_agreement, mock_verify, mock_cliprofile_namespace +): + mock_verify.return_value = False + mock_cliprofile_namespace.profile_exists.return_value = False + success = False + try: + profilecmd.create_profile("foo", "bar", "baz", True) + except SystemExit: + success = True + assert not mock_cliprofile_namespace.set_password.call_count + assert success + + +def test_create_profile_if_credentials_valid_password_saved( + mocker, user_agreement, mock_verify, mock_cliprofile_namespace +): + mock_verify.return_value = True + mock_cliprofile_namespace.profile_exists.return_value = False + profilecmd.create_profile("foo", "bar", "baz", True) + mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) + + +def test_prompt_for_password_reset_if_credentials_valid_password_saved( + mocker, user_agreement, mock_verify, mock_cliprofile_namespace +): + mock_verify.return_value = True + mock_cliprofile_namespace.profile_exists.return_value = False + profilecmd.prompt_for_password_reset() + mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) + + +def test_prompt_for_password_reset_if_credentials_invalid_password_not_saved( + user_agreement, mock_verify, mock_cliprofile_namespace +): + mock_verify.return_value = False + mock_cliprofile_namespace.profile_exists.return_value = False + success = False + try: + profilecmd.prompt_for_password_reset() + except SystemExit: + success = True + assert not mock_cliprofile_namespace.set_password.call_count + assert success + + +def test_list_profiles(capsys, mock_cliprofile_namespace): + profiles = [ + create_mock_profile("one"), + create_mock_profile("two"), + create_mock_profile("three"), + ] + mock_cliprofile_namespace.get_all_profiles.return_value = profiles + profilecmd.list_profiles() + capture = capsys.readouterr() + assert "one" in capture.out + assert "two" in capture.out + assert "three" in capture.out + + +def test_list_profiles_when_no_profiles_outputs_no_profiles_message( + capsys, mock_cliprofile_namespace +): + mock_cliprofile_namespace.get_all_profiles.return_value = [] + profilecmd.list_profiles() + capture = capsys.readouterr() + assert "No existing profile." in capture.out + + +def test_use_profile(mock_cliprofile_namespace, profile): + profilecmd.use_profile(profile) + mock_cliprofile_namespace.switch_default_profile.assert_called_once_with(profile) diff --git a/tests/conftest.py b/tests/conftest.py index d153fab27..66d5e95cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,46 +1,56 @@ -import json as json_module -from argparse import Namespace -from datetime import datetime, timedelta - import pytest +from argparse import Namespace +from py42.sdk import SDKClient -from code42cli.profile.config import ConfigAccessor -from code42cli.profile.profile import Code42Profile +from code42cli.config import ConfigAccessor +from code42cli.profile import Code42Profile @pytest.fixture def namespace(mocker): mock = mocker.MagicMock(spec=Namespace) - mock.profile_name = None - mock.is_incremental = None + mock.incremental = None mock.advanced_query = None - mock.is_debug_mode = None - mock.begin_date = None - mock.end_date = None - mock.exposure_types = None - mock.c42usernames = None - mock.actors = None - mock.md5_hashes = None - mock.sha256_hashes = None - mock.sources = None - mock.filenames = None - mock.filepaths = None - mock.process_owners = None - mock.tab_urls = None - mock.include_non_exposure_events = None + mock.begin = None + mock.end = None + mock.type = None + mock.c42username = None + mock.actor = None + mock.md5 = None + mock.sha256 = None + mock.source = None + mock.filename = None + mock.filepath = None + mock.processOwner = None + mock.tabURL = None + mock.include_non_exposure = None + mock.format = None + mock.output_file = None + mock.server = None + mock.protocol = None return mock def create_profile_values_dict(authority=None, username=None, ignore_ssl=False): return { - ConfigAccessor.AUTHORITY_KEY: authority, - ConfigAccessor.USERNAME_KEY: username, - ConfigAccessor.IGNORE_SSL_ERRORS_KEY: ignore_ssl, + ConfigAccessor.AUTHORITY_KEY: "example.com", + ConfigAccessor.USERNAME_KEY: "foo", + ConfigAccessor.IGNORE_SSL_ERRORS_KEY: True, } +@pytest.fixture +def sdk(mocker): + return mocker.MagicMock(spec=SDKClient) + + +@pytest.fixture() +def mock_42(mocker): + return mocker.patch("py42.sdk.from_local_account") + + class MockSection(object): - def __init__(self, name="Test Profile Name", values_dict=None): + def __init__(self, name=None, values_dict=None): self.name = name self.values_dict = values_dict or create_profile_values_dict() @@ -54,14 +64,9 @@ def get(self, item): return self.values_dict.get(item) -def create_mock_profile(name=None): +def create_mock_profile(name="Test Profile Name"): profile_section = MockSection(name) profile = Code42Profile(profile_section) - - def mock_get_password(): - return "Test Password" - - profile.get_password = mock_get_password return profile @@ -71,23 +76,26 @@ def setup_mock_accessor(mock_accessor, name=None, values_dict=None): return mock_accessor -def get_filter_value_from_json(json, filter_index): - return json_module.loads(str(json))["filters"][filter_index]["value"] +@pytest.fixture +def profile(mocker): + return mocker.MagicMock(spec=Code42Profile) + + +def func_keyword_args(one=None, two=None, three=None, default="testdefault", nargstest=[]): + pass -def parse_date_from_filter_value(json, filter_index): - date_str = get_filter_value_from_json(json, filter_index) - return convert_str_to_date(date_str) +def func_positional_args(one, two, three): + pass -def convert_str_to_date(date_str): - return datetime.strptime(date_str, u"%Y-%m-%dT%H:%M:%S.%fZ") +def func_mixed_args(one, two, three=None, four=None): + pass -def get_test_date(days_ago): - now = datetime.utcnow() - return now - timedelta(days=days_ago) +def func_with_sdk(sdk, one, two, three=None, four=None): + pass -def get_test_date_str(days_ago): - return get_test_date(days_ago).strftime("%Y-%m-%d") +def func_with_args(args): + pass diff --git a/tests/profile/__init__.py b/tests/profile/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/profile/conftest.py b/tests/profile/conftest.py deleted file mode 100644 index 03503b13d..000000000 --- a/tests/profile/conftest.py +++ /dev/null @@ -1,3 +0,0 @@ -PROFILE_NAMESPACE = "code42cli.profile" -CONFIG_NAMESPACE = "{0}.config".format(PROFILE_NAMESPACE) -PASSWORD_NAMESPACE = "{0}.password".format(PROFILE_NAMESPACE) diff --git a/tests/profile/test_password.py b/tests/profile/test_password.py deleted file mode 100644 index 39aea0a06..000000000 --- a/tests/profile/test_password.py +++ /dev/null @@ -1,163 +0,0 @@ -import pytest - -import code42cli.profile.password as password -from code42cli.profile.config import ConfigAccessor -from .conftest import PASSWORD_NAMESPACE -from ..conftest import setup_mock_accessor, create_profile_values_dict - -_USERNAME = "test.username" - - -@pytest.fixture -def config_accessor(mocker): - mock = mocker.MagicMock(spec=ConfigAccessor) - factory = mocker.patch("{0}.get_config_accessor".format(PASSWORD_NAMESPACE)) - factory.return_value = mock - return mock - - -@pytest.fixture -def keyring_password_getter(mocker): - return mocker.patch("keyring.get_password") - - -@pytest.fixture(autouse=True) -def keyring_password_setter(mocker): - return mocker.patch("keyring.set_password") - - -@pytest.fixture -def getpass_function(mocker): - return mocker.patch("code42cli.profile.password.getpass") - - -@pytest.fixture -def user_agreement(mocker): - mock = mocker.patch("code42cli.profile.password.does_user_agree") - mock.return_value = True - return mock - - -@pytest.fixture -def user_disagreement(mocker): - mock = mocker.patch("code42cli.profile.password.does_user_agree") - mock.return_value = False - return mock - - -@pytest.fixture -def file_opener(mocker): - return mocker.patch("code42cli.profile.password.open_file") - - -def test_get_stored_password_when_given_profile_name_gets_profile_for_that_name( - keyring_password_getter, config_accessor -): - password.get_stored_password("profile_name") - assert config_accessor.get_profile.call_args_list[0][0][0] == "profile_name" - - -def test_get_stored_password_returns_expected_password( - keyring_password_getter, config_accessor, keyring_password_setter -): - keyring_password_getter.return_value = "already stored password 123" - assert password.get_stored_password("profile_name") == "already stored password 123" - - -def test_get_stored_password_when_keyring_returns_none_returns_password_from_file( - keyring_password_getter, config_accessor, file_opener -): - keyring_password_getter.return_value = None - file_opener.return_value = "FileStoredPassword123!" - assert password.get_stored_password("profile_name") == "FileStoredPassword123!" - - -def test_get_stored_password_when_keyring_throws_exception_returns_password_from_file( - keyring_password_getter, config_accessor, file_opener -): - def side_effect(*args): - raise Exception() - - keyring_password_getter.side_effect = side_effect - file_opener.return_value = "FileStoredPassword123!" - assert password.get_stored_password("profile_name") == "FileStoredPassword123!" - - -def test_get_stored_password_when_not_in_keyring_or_file_returns_none( - keyring_password_getter, config_accessor, file_opener -): - keyring_password_getter.return_value = None - file_opener.return_value = None - assert not password.get_stored_password("profile_name") - - -def test_get_stored_password_when_not_in_keyring_and_getting_from_file_throws_returns_none( - keyring_password_getter, config_accessor, file_opener -): - keyring_password_getter.return_value = None - - def side_effect(*args): - raise Exception() - - file_opener.side_effect = side_effect - assert not password.get_stored_password("profile_name") - - -def test_set_password_uses_expected_service_name_username_and_password( - keyring_password_setter, config_accessor, keyring_password_getter -): - keyring_password_getter.return_value = "test_password" - values = create_profile_values_dict(username="test.username") - setup_mock_accessor(config_accessor, "profile_name", values) - password.set_password("profile_name", "test_password") - expected_service_name = "code42cli::profile_name" - expected_username = "test.username" - keyring_password_setter.assert_called_once_with( - expected_service_name, expected_username, "test_password" - ) - - -def test_set_password_when_given_none_uses_password_from_default_profile( - keyring_password_setter, config_accessor, keyring_password_getter -): - keyring_password_getter.return_value = "test_password" - values = create_profile_values_dict(username="test.username") - setup_mock_accessor(config_accessor, "Default_Profile", values) - config_accessor.name = "Default_Profile" - password.set_password(None, "test_password") - expected_service_name = "code42cli::Default_Profile" - expected_username = "test.username" - keyring_password_setter.assert_called_once_with( - expected_service_name, expected_username, "test_password" - ) - - -def test_set_password_when_not_found_in_keyring_after_set_and_user_agrees_stores_in_file( - keyring_password_setter, config_accessor, keyring_password_getter, user_agreement, file_opener -): - keyring_password_getter.return_value = None - password.set_password("profile_name", "test_password") - assert file_opener.call_count == 1 - - -def test_set_password_when_not_found_in_keyring_after_set_and_user_disagrees_does_not_store( - keyring_password_setter, - config_accessor, - keyring_password_getter, - user_disagreement, - file_opener, -): - keyring_password_getter.return_value = None - password.set_password("profile_name", "test_password") - assert file_opener.call_count == 0 - - -def test_set_password_when_keyring_throws_and_user_agrees_stores_in_file( - keyring_password_setter, config_accessor, user_agreement, file_opener -): - def side_effect(*args): - raise Exception() - - keyring_password_setter.side_effect = side_effect - password.set_password("profile_name", "test_password") - assert file_opener.call_count == 1 diff --git a/tests/profile/test_profile.py b/tests/profile/test_profile.py deleted file mode 100644 index c68567d2c..000000000 --- a/tests/profile/test_profile.py +++ /dev/null @@ -1,294 +0,0 @@ -from argparse import ArgumentParser - -import pytest - -from code42cli.profile import profile -from code42cli.profile.config import ConfigAccessor -from .conftest import PASSWORD_NAMESPACE, PROFILE_NAMESPACE -from ..conftest import MockSection, create_mock_profile - - -@pytest.fixture -def config_accessor(mocker): - mock = mocker.MagicMock(spec=ConfigAccessor, name="Config Accessor") - factory = mocker.patch("{0}.profile.get_config_accessor".format(PROFILE_NAMESPACE)) - factory.return_value = mock - return mock - - -@pytest.fixture(autouse=True) -def password_setter(mocker): - return mocker.patch("{0}.set_password".format(PASSWORD_NAMESPACE)) - - -@pytest.fixture(autouse=True) -def password_getter(mocker): - return mocker.patch("{0}.get_stored_password".format(PASSWORD_NAMESPACE)) - - -@pytest.fixture -def user_agreement(mocker): - mock = mocker.patch("{0}.profile.does_user_agree".format(PROFILE_NAMESPACE)) - mock.return_value = True - return mocker - - -@pytest.fixture -def user_disagreement(mocker): - mock = mocker.patch("{0}.profile.does_user_agree".format(PROFILE_NAMESPACE)) - mock.return_value = False - return mocker - - -def _get_arg_parser(): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - return subcommand_parser.choices.get("profile") - - -class TestCode42Profile(object): - def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter): - password_getter.return_value = None - mock_getpass = mocker.patch("{0}.get_password_from_prompt".format(PASSWORD_NAMESPACE)) - mock_getpass.return_value = "Test Password" - actual = create_mock_profile().get_password() - assert actual == "Test Password" - - def test_get_password_return_password_from_password_get_password(self, password_getter): - password_getter.return_value = "Test Password" - actual = create_mock_profile().get_password() - assert actual == "Test Password" - - -def test_init_adds_profile_subcommand_to_choices(config_accessor): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - assert subcommand_parser.choices.get("profile") - - -def test_init_adds_parser_that_can_parse_show_command_without_profile(config_accessor): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - profile_parser = subcommand_parser.choices.get("profile") - assert profile_parser.parse_args(["show"]) - - -def test_init_adds_parser_that_can_parse_show_command_with_profile(config_accessor): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - profile_parser = subcommand_parser.choices.get("profile") - assert profile_parser.parse_args(["show", "--profile", "name"]) - - -def test_init_adds_parser_that_can_parse_set_command_without_profile(config_accessor): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - profile_parser = subcommand_parser.choices.get("profile") - profile_parser.parse_args( - ["set", "-s", "server-arg", "-u", "username-arg", "--enable-ssl-errors"] - ) - - -def test_init_adds_parser_that_can_parse_set_command_with_profile(config_accessor): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - profile_parser = subcommand_parser.choices.get("profile") - profile_parser.parse_args( - [ - "set", - "--profile", - "ProfileName", - "-s", - "server-arg", - "-u", - "username-arg", - "--enable-ssl-errors", - ] - ) - - -def test_init_add_parser_that_can_parse_list_command(): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - profile_parser = subcommand_parser.choices.get("profile") - assert profile_parser.parse_args(["list"]) - - -def test_init_add_parser_that_can_parse_use_command(): - subcommand_parser = ArgumentParser().add_subparsers() - profile.init(subcommand_parser) - profile_parser = subcommand_parser.choices.get("profile") - assert profile_parser.parse_args(["use", "name"]) - - -def test_get_profile_returns_object_from_config_profile(mocker, config_accessor): - expected = mocker.MagicMock() - config_accessor.get_profile.return_value = expected - user = profile.get_profile() - assert user._profile == expected - - -def test_set_profile_when_given_username_sets_username(config_accessor, user_disagreement): - parser = _get_arg_parser() - namespace = parser.parse_args(["set", "-u", "a.new.user@example.com"]) - profile.set_profile(namespace) - assert config_accessor.set_username.call_args[0][0] == "a.new.user@example.com" - - -def test_set_profile_when_given_profile_name_sets_username_for_profile( - config_accessor, user_disagreement -): - parser = _get_arg_parser() - namespace = parser.parse_args(["set", "--profile", "profileA", "-u", "a.new.user@example.com"]) - profile.set_profile(namespace) - assert config_accessor.set_username.call_args[0][0] == "a.new.user@example.com" - assert config_accessor.set_username.call_args[0][1] == "profileA" - - -def test_set_profile_when_given_authority_sets_authority(config_accessor, user_disagreement): - parser = _get_arg_parser() - namespace = parser.parse_args(["set", "-s", "example.com"]) - profile.set_profile(namespace) - assert config_accessor.set_authority_url.call_args[0][0] == "example.com" - - -def test_set_profile_when_given_profile_name_sets_authority_for_profile( - config_accessor, user_disagreement -): - parser = _get_arg_parser() - namespace = parser.parse_args(["set", "--profile", "profileA", "-s", "example.com"]) - profile.set_profile(namespace) - assert config_accessor.set_authority_url.call_args[0] == ("example.com", "profileA") - - -def test_set_profile_when_given_enable_ssl_errors_sets_ignore_ssl_errors_to_true( - config_accessor, user_disagreement -): - parser = _get_arg_parser() - namespace = parser.parse_args(["set", "--enable-ssl-errors"]) - profile.set_profile(namespace) - assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == False - - -def test_set_profile_when_given_disable_ssl_errors_sets_ignore_ssl_errors_to_true( - config_accessor, user_disagreement -): - parser = _get_arg_parser() - namespace = parser.parse_args(["set", "--disable-ssl-errors"]) - profile.set_profile(namespace) - assert config_accessor.set_ignore_ssl_errors.call_args[0][0] == True - - -def test_set_profile_when_given_disable_ssl_errors_and_profile_name_sets_ignore_ssl_errors_to_true_for_profile( - config_accessor, user_disagreement -): - parser = _get_arg_parser() - namespace = parser.parse_args(["set", "--profile", "profileA", "--disable-ssl-errors"]) - profile.set_profile(namespace) - assert config_accessor.set_ignore_ssl_errors.call_args[0] == (True, "profileA") - - -def test_set_profile_when_to_store_password_prompts_for_storing_password( - mocker, config_accessor, user_agreement -): - mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") - mock_successful_connection.return_value = True - mocker.patch("code42cli.profile.password.get_password_from_prompt") - mock_set_password_function = mocker.patch("code42cli.profile.password.set_password") - parser = _get_arg_parser() - namespace = parser.parse_args( - ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] - ) - profile.set_profile(namespace) - assert mock_set_password_function.call_count - - -def test_set_profile_when_told_not_to_store_password_does_not_prompt_for_storing_password( - mocker, config_accessor, user_disagreement -): - mocker.patch("code42cli.profile.password.get_password_from_prompt") - parser = _get_arg_parser() - mock_set_password_function = mocker.patch("code42cli.profile.password.set_password") - namespace = parser.parse_args( - ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] - ) - profile.set_profile(namespace) - assert not mock_set_password_function.call_count - - -def test_set_profile_when_told_to_store_password_but_connection_fails_exits( - mocker, config_accessor, user_agreement -): - mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") - mock_successful_connection.return_value = False - mocker.patch("code42cli.profile.password.get_password_from_prompt") - parser = _get_arg_parser() - namespace = parser.parse_args( - ["set", "-s", "https://wwww.new.authority.example.com", "-u", "user"] - ) - with pytest.raises(SystemExit): - profile.set_profile(namespace) - - -def test_prompt_for_password_reset_when_connection_fails_does_not_reset_password( - mocker, config_accessor, user_agreement -): - mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") - mock_successful_connection.return_value = False - mocker.patch("code42cli.profile.password.get_password_from_prompt") - parser = _get_arg_parser() - namespace = parser.parse_args(["reset-pw", "--profile", "Test"]) - with pytest.raises(SystemExit): - profile.prompt_for_password_reset(namespace) - - -def test_prompt_for_password_when_not_given_profile_name_calls_set_password_with_default_profile( - mocker, config_accessor, user_agreement -): - default_profile = MockSection() - config_accessor.get_profile.return_value = default_profile - mock_successful_connection = mocker.patch("code42cli.profile.profile.validate_connection") - mock_successful_connection.return_value = True - password_prompt = mocker.patch("code42cli.profile.password.get_password_from_prompt") - password_prompt.return_value = "new password" - parser = _get_arg_parser() - namespace = parser.parse_args(["reset-pw"]) - mock_set_password_function = mocker.patch("code42cli.profile.password.set_password") - profile.prompt_for_password_reset(namespace) - mock_set_password_function.assert_called_once_with(default_profile.name, "new password") - - -def test_list_profiles_when_no_profiles_prints_error(mocker, config_accessor): - config_accessor.get_all_profiles.return_value = [] - mock_error_printer = mocker.patch("code42cli.util.print_error") - parser = _get_arg_parser() - namespace = parser.parse_args(["list"]) - profile.list_profiles(namespace) - mock_error_printer.assert_called_once_with("No existing profile.") - - -def test_list_profiles_when_profiles_exists_does_not_print_error(mocker, config_accessor): - config_accessor.get_all_profiles.return_value = [MockSection()] - mock_error_printer = mocker.patch("code42cli.util.print_error") - parser = _get_arg_parser() - namespace = parser.parse_args(["list"]) - profile.list_profiles(namespace) - assert not mock_error_printer.call_count - - -def test_use_profile_when_switching_fails_causes_exit(config_accessor): - def side_effect(*args): - raise Exception() - - config_accessor.switch_default_profile.side_effect = side_effect - parser = _get_arg_parser() - namespace = parser.parse_args(["use", "TestProfile"]) - with pytest.raises(SystemExit): - profile.use_profile(namespace) - - -def test_use_profile_calls_accessor_with_expected_profile_name(config_accessor): - parser = _get_arg_parser() - namespace = parser.parse_args(["use", "TestProfile"]) - profile.use_profile(namespace) - config_accessor.switch_default_profile.assert_called_once_with("TestProfile") diff --git a/tests/securitydata/__init__.py b/tests/securitydata/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/securitydata/conftest.py b/tests/securitydata/conftest.py deleted file mode 100644 index 1e2f6256d..000000000 --- a/tests/securitydata/conftest.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -from tests.conftest import get_test_date_str - -SECURITYDATA_NAMESPACE = "code42cli.securitydata" -SUBCOMMANDS_NAMESPACE = "{0}.subcommands".format(SECURITYDATA_NAMESPACE) - -begin_date_str = get_test_date_str(days_ago=89) -begin_date_str_with_time = "{0} 3:12:33".format(begin_date_str) -end_date_str = get_test_date_str(days_ago=10) -end_date_str_with_time = "{0} 11:22:43".format(end_date_str) -begin_date_list = [get_test_date_str(days_ago=89)] -begin_date_list_with_time = [get_test_date_str(days_ago=89), "3:12:33"] -end_date_list = [get_test_date_str(days_ago=10)] -end_date_list_with_time = [get_test_date_str(days_ago=10), "11:22:43"] - - -@pytest.fixture(autouse=True) -def sqlite_connection(mocker): - return mocker.patch("sqlite3.connect") diff --git a/tests/securitydata/subcommands/__init__.py b/tests/securitydata/subcommands/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/securitydata/subcommands/conftest.py b/tests/securitydata/subcommands/conftest.py deleted file mode 100644 index 4f6907cbd..000000000 --- a/tests/securitydata/subcommands/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -ACCEPTABLE_ARGS = [ - "-t", - "SharedToDomain", - "ApplicationRead", - "CloudStorage", - "RemovableMedia", - "IsPublic", - "-f", - "JSON", - "-d", - "-b", - "600", - "-e", - "2020-02-02", - "--c42username", - "test.testerson", - "--actor", - "test.testerson", - "--md5", - "098f6bcd4621d373cade4e832627b4f6", - "--sha256", - "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", - "--source", - "Gmail", - "--filename", - "file.txt", - "--filepath", - "/path/to/file.txt", - "--processOwner", - "test.testerson", - "--tabURL", - "https://example.com", - "--include-non-exposure", -] diff --git a/tests/securitydata/subcommands/test_clear_checkpoint.py b/tests/securitydata/subcommands/test_clear_checkpoint.py deleted file mode 100644 index 5f5717cda..000000000 --- a/tests/securitydata/subcommands/test_clear_checkpoint.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest - -from code42cli.securitydata.subcommands import clear_checkpoint as clearer -from ..conftest import SECURITYDATA_NAMESPACE - -_CURSOR_STORE_NAMESPACE = "{0}.cursor_store".format(SECURITYDATA_NAMESPACE) - - -@pytest.fixture -def cursor_store(mocker): - mock_init = mocker.patch("{0}.FileEventCursorStore.__init__".format(_CURSOR_STORE_NAMESPACE)) - mock_init.return_value = None - mock = mocker.MagicMock() - mock_new = mocker.patch("{0}.FileEventCursorStore.__new__".format(_CURSOR_STORE_NAMESPACE)) - mock_new.return_value = mock - return mock - - -@pytest.fixture -def profile(mocker): - class MockProfile(object): - @property - def name(self): - return "AlreadySetProfileName" - - mock = mocker.patch( - "{0}.subcommands.clear_checkpoint.get_profile".format(SECURITYDATA_NAMESPACE) - ) - mock.return_value = MockProfile() - return mock - - -def test_clear_checkpoint_when_given_profile_name_calls_cursor_store_resets( - cursor_store, namespace -): - namespace.profile_name = "Test" - clearer.clear_checkpoint(namespace) - assert cursor_store.replace_stored_insertion_timestamp.call_args[0][0] is None - - -def test_clear_checkpoint_calls_cursor_store_resets(cursor_store, namespace, profile): - clearer.clear_checkpoint(namespace) - assert cursor_store.replace_stored_insertion_timestamp.call_args[0][0] is None diff --git a/tests/securitydata/subcommands/test_print_out.py b/tests/securitydata/subcommands/test_print_out.py deleted file mode 100644 index 21a81281b..000000000 --- a/tests/securitydata/subcommands/test_print_out.py +++ /dev/null @@ -1,42 +0,0 @@ -from argparse import ArgumentParser - -import pytest - -import code42cli.securitydata.subcommands.print_out as printer -from .conftest import ACCEPTABLE_ARGS -from ..conftest import SUBCOMMANDS_NAMESPACE - -_PRINT_PATH = "{0}.print_out".format(SUBCOMMANDS_NAMESPACE) - - -@pytest.fixture -def logger_factory(mocker): - return mocker.patch("{0}.get_logger_for_stdout".format(_PRINT_PATH)) - - -@pytest.fixture -def extractor(mocker): - return mocker.patch("{0}.extract".format(_PRINT_PATH)) - - -def test_init_adds_parser_that_can_parse_supported_args(): - subcommand_parser = ArgumentParser().add_subparsers() - printer.init(subcommand_parser) - print_parser = subcommand_parser.choices.get("print") - print_parser.parse_args(ACCEPTABLE_ARGS) - - -def test_print_out_uses_logger_for_stdout(namespace, logger_factory, extractor): - namespace.format = "CEF" - printer.print_out(namespace) - logger_factory.assert_called_once_with("CEF") - - -def test_print_out_calls_extract_with_expected_arguments( - mocker, namespace, logger_factory, extractor -): - namespace.format = "CEF" - logger = mocker.MagicMock() - logger_factory.return_value = logger - printer.print_out(namespace) - extractor.assert_called_once_with(logger, namespace) diff --git a/tests/securitydata/subcommands/test_send_to.py b/tests/securitydata/subcommands/test_send_to.py deleted file mode 100644 index 0ccea50c0..000000000 --- a/tests/securitydata/subcommands/test_send_to.py +++ /dev/null @@ -1,73 +0,0 @@ -from argparse import ArgumentParser - -import pytest - -from code42cli.securitydata.subcommands import send_to as sender -from .conftest import ACCEPTABLE_ARGS -from ..conftest import SUBCOMMANDS_NAMESPACE - -_SEND_PATH = "{0}.send_to".format(SUBCOMMANDS_NAMESPACE) - - -@pytest.fixture -def server_namespace(namespace): - namespace.server = "www.syslog.example.com" - namespace.protocol = "TCP" - namespace.format = "CEF" - return namespace - - -@pytest.fixture -def logger_factory(mocker): - return mocker.patch("{0}.get_logger_for_server".format(_SEND_PATH)) - - -@pytest.fixture -def extractor(mocker): - return mocker.patch("{0}.extract".format(_SEND_PATH)) - - -def test_init_adds_parser_that_can_parse_supported_args(): - subcommand_parser = ArgumentParser().add_subparsers() - sender.init(subcommand_parser) - send_parser = subcommand_parser.choices.get("send-to") - args = ["https://www.syslog.com", "-p", "UDP"] + ACCEPTABLE_ARGS - send_parser.parse_args(args) - - -def test_init_adds_parser_when_not_given_server_causes_system_exit(): - subcommand_parser = ArgumentParser().add_subparsers() - sender.init(subcommand_parser) - send_parser = subcommand_parser.choices.get("send-to") - with pytest.raises(SystemExit): - send_parser.parse_args( - [ - "-t", - "SharedToDomain", - "ApplicationRead", - "CloudStorage", - "RemovableMedia", - "IsPublic", - "-f", - "JSON", - "-d", - "-b", - "600", - "-e", - "2020-02-02", - ] - ) - - -def test_send_to_uses_logger_for_server(server_namespace, logger_factory, extractor): - sender.send_to(server_namespace) - logger_factory.assert_called_once_with("www.syslog.example.com", "TCP", "CEF") - - -def test_send_to_calls_extract_with_expected_arguments( - mocker, server_namespace, logger_factory, extractor -): - logger = mocker.MagicMock() - logger_factory.return_value = logger - sender.send_to(server_namespace) - extractor.assert_called_once_with(logger, server_namespace) diff --git a/tests/securitydata/subcommands/test_write_to.py b/tests/securitydata/subcommands/test_write_to.py deleted file mode 100644 index 1abbe4771..000000000 --- a/tests/securitydata/subcommands/test_write_to.py +++ /dev/null @@ -1,72 +0,0 @@ -from argparse import ArgumentParser - -import pytest - -from code42cli.securitydata.subcommands import write_to as writer -from .conftest import ACCEPTABLE_ARGS -from ..conftest import SUBCOMMANDS_NAMESPACE - -_WRITE_PATH = "{0}.write_to".format(SUBCOMMANDS_NAMESPACE) - - -@pytest.fixture -def file_namespace(namespace): - namespace.output_file = "out.txt" - namespace.format = "CEF" - return namespace - - -@pytest.fixture -def logger_factory(mocker): - return mocker.patch("{0}.get_logger_for_file".format(_WRITE_PATH)) - - -@pytest.fixture -def extractor(mocker): - return mocker.patch("{0}.extract".format(_WRITE_PATH)) - - -def test_init_adds_parser_that_can_parse_supported_args(): - subcommand_parser = ArgumentParser().add_subparsers() - writer.init(subcommand_parser) - write_parser = subcommand_parser.choices.get("write-to") - args = ["out.txt"] + ACCEPTABLE_ARGS - write_parser.parse_args(args) - - -def test_init_adds_parser_when_not_given_filename_causes_system_exit(): - subcommand_parser = ArgumentParser().add_subparsers() - writer.init(subcommand_parser) - write_parser = subcommand_parser.choices.get("write-to") - with pytest.raises(SystemExit): - write_parser.parse_args( - [ - "-t", - "SharedToDomain", - "ApplicationRead", - "CloudStorage", - "RemovableMedia", - "IsPublic", - "-f", - "JSON", - "-d", - "-b", - "600", - "-e", - "2020-02-02", - ] - ) - - -def test_write_to_uses_logger_for_file(file_namespace, logger_factory, extractor): - writer.write_to(file_namespace) - logger_factory.assert_called_once_with("out.txt", "CEF") - - -def test_write_to_calls_extract_with_expected_arguments( - mocker, file_namespace, logger_factory, extractor -): - logger = mocker.MagicMock() - logger_factory.return_value = logger - writer.write_to(file_namespace) - extractor.assert_called_once_with(logger, file_namespace) diff --git a/tests/securitydata/test_extraction.py b/tests/securitydata/test_extraction.py deleted file mode 100644 index 87f5ffb27..000000000 --- a/tests/securitydata/test_extraction.py +++ /dev/null @@ -1,531 +0,0 @@ -import pytest -from py42.sdk.queries.fileevents.filters import * - -import code42cli.securitydata.extraction as extraction_module -from code42cli.securitydata.options import ExposureType as ExposureTypeOptions -from .conftest import SECURITYDATA_NAMESPACE, begin_date_str -from ..conftest import get_filter_value_from_json, get_test_date_str - - -@pytest.fixture(autouse=True) -def mock_42(mocker): - return mocker.patch("py42.sdk.from_local_account") - - -@pytest.fixture -def logger(mocker): - mock = mocker.MagicMock() - mock.info = mocker.MagicMock() - return mock - - -@pytest.fixture(autouse=True) -def error_logger(mocker): - return mocker.patch("{0}.extraction.get_error_logger".format(SECURITYDATA_NAMESPACE)) - - -@pytest.fixture -def extractor(mocker): - mock = mocker.MagicMock() - mock.extract_advanced = mocker.patch( - "c42eventextractor.extractors.FileEventExtractor.extract_advanced" - ) - mock.extract = mocker.patch("c42eventextractor.extractors.FileEventExtractor.extract") - return mock - - -@pytest.fixture(autouse=True) -def profile(mocker): - mocker.patch("code42cli.securitydata.extraction.get_profile") - - -@pytest.fixture -def namespace_with_begin(namespace): - namespace.begin_date = begin_date_str - return namespace - - -def filter_term_is_in_call_args(extractor, term): - arg_filters = extractor.extract.call_args[0] - for f in arg_filters: - if term in str(f): - return True - return False - - -def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( - logger, namespace, extractor -): - namespace.advanced_query = "some complex json" - extraction_module.extract(logger, namespace) - extractor.extract_advanced.assert_called_once_with("some complex json") - assert extractor.extract.call_count == 0 - - -def test_extract_when_is_advanced_query_and_has_begin_date_exits(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.begin_date = "begin date" - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_end_date_exits(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.end_date = "end date" - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_exposure_types_exits(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.exposure_types = [ExposureTypeOptions.SHARED_TO_DOMAIN] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_username_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.c42usernames = ["Someone"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_actor_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.actors = ["Someone"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_md5_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.md5_hashes = ["098f6bcd4621d373cade4e832627b4f6"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_sha256_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.sha256_hashes = ["9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_source_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.sources = ["Gmail"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_filename_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.filenames = ["test.out"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_filepath_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.filepaths = ["path/to/file"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_process_owner_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.process_owners = ["someone"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_tab_url_exists(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.tab_urls = ["https://www.example.com"] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_incremental_mode_exits(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.is_incremental = True - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_include_non_exposure_exits(logger, namespace): - namespace.advanced_query = "some complex json" - namespace.include_non_exposure_events = True - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_include_non_exposure_is_false_does_not_exit( - logger, namespace -): - namespace.include_non_exposure_events = False - namespace.advanced_query = "some complex json" - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( - logger, namespace -): - namespace.advanced_query = "some complex json" - namespace.is_incremental = False - extraction_module.extract(logger, namespace) - - -def test_extract_when_is_not_advanced_query_uses_only_extract_method( - logger, extractor, namespace_with_begin -): - extraction_module.extract(logger, namespace_with_begin) - assert extractor.extract.call_count == 1 - assert extractor.extract_raw.call_count == 0 - - -def test_extract_when_not_given_begin_or_advanced_causes_exit(logger, extractor, namespace): - namespace.begin_date = None - namespace.advanced_query = None - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_given_begin_date_uses_expected_query(logger, namespace, extractor): - namespace.begin_date = get_test_date_str(days_ago=89) - extraction_module.extract(logger, namespace) - actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T00:00:00.000Z".format(namespace.begin_date) - assert actual == expected - - -def test_extract_when_given_begin_date_and_time_uses_expected_query(logger, namespace, extractor): - date = get_test_date_str(days_ago=89) - time = "15:33:02" - namespace.begin_date = get_test_date_str(days_ago=89) + " " + time - extraction_module.extract(logger, namespace) - actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T{1}.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_given_end_date_uses_expected_query(logger, namespace_with_begin, extractor): - namespace_with_begin.end_date = get_test_date_str(days_ago=10) - extraction_module.extract(logger, namespace_with_begin) - actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T23:59:59.999Z".format(namespace_with_begin.end_date) - assert actual == expected - - -def test_extract_when_given_end_date_and_time_uses_expected_query( - logger, namespace_with_begin, extractor -): - date = get_test_date_str(days_ago=10) - time = "12:00:11" - namespace_with_begin.end_date = date + " " + time - extraction_module.extract(logger, namespace_with_begin) - actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T{1}.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( - logger, namespace, extractor -): - end_date = get_test_date_str(days_ago=55) - end_time = "13:44:44" - namespace.begin_date = get_test_date_str(days_ago=89) - namespace.end_date = end_date + " " + end_time - extraction_module.extract(logger, namespace) - - actual_begin_timestamp = get_filter_value_from_json( - extractor.extract.call_args[0][0], filter_index=0 - ) - actual_end_timestamp = get_filter_value_from_json( - extractor.extract.call_args[0][0], filter_index=1 - ) - expected_begin_timestamp = "{0}T00:00:00.000Z".format(namespace.begin_date) - expected_end_timestamp = "{0}T{1}.000Z".format(end_date, end_time) - - assert actual_begin_timestamp == expected_begin_timestamp - assert actual_end_timestamp == expected_end_timestamp - - -def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( - logger, namespace, extractor -): - namespace.is_incremental = False - date = get_test_date_str(days_ago=91) + " 12:51:00" - namespace.begin_date = date - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_end_date_is_before_begin_date_causes_exit(logger, namespace, extractor): - namespace.begin_date = get_test_date_str(days_ago=5) - namespace.end_date = get_test_date_str(days_ago=6) - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - mocker, logger, namespace, extractor -): - namespace.begin_date = "2019-01-01" - namespace.is_incremental = True - mock_checkpoint = mocker.patch( - "code42cli.securitydata.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" - ) - mock_checkpoint.return_value = 22624624 - extraction_module.extract(logger, namespace) - assert not filter_term_is_in_call_args(extractor, EventTimestamp._term) - - -def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( - mocker, logger, namespace, extractor -): - namespace.begin_date = get_test_date_str(days_ago=1) - namespace.is_incremental = False - mock_checkpoint = mocker.patch( - "code42cli.securitydata.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" - ) - mock_checkpoint.return_value = 22624624 - extraction_module.extract(logger, namespace) - - actual_ts = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) - expected_ts = "{0}T00:00:00.000Z".format(namespace.begin_date) - assert actual_ts == expected_ts - assert filter_term_is_in_call_args(extractor, EventTimestamp._term) - - -def test_when_not_given_begin_date_and_is_incremental_but_no_stored_checkpoint_exists_causes_exit( - mocker, logger, namespace, extractor -): - namespace.begin_date = None - namespace.is_incremental = True - mock_checkpoint = mocker.patch( - "code42cli.securitydata.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" - ) - mock_checkpoint.return_value = None - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_given_invalid_exposure_type_causes_exit(logger, namespace, extractor): - namespace.exposure_types = [ - ExposureTypeOptions.APPLICATION_READ, - "SomethingElseThatIsNotSupported", - ExposureTypeOptions.IS_PUBLIC, - ] - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_given_username_uses_username_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.c42usernames = ["test.testerson@example.com"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - DeviceUsername.is_in(namespace_with_begin.c42usernames) - ) - - -def test_extract_when_given_actor_uses_actor_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.actors = ["test.testerson"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(Actor.is_in(namespace_with_begin.actors)) - - -def test_extract_when_given_md5_uses_md5_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.md5_hashes = ["098f6bcd4621d373cade4e832627b4f6"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(MD5.is_in(namespace_with_begin.md5_hashes)) - - -def test_extract_when_given_sha256_uses_sha256_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.sha256_hashes = [ - "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" - ] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - SHA256.is_in(namespace_with_begin.sha256_hashes) - ) - - -def test_extract_when_given_source_uses_source_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.sources = ["Gmail", "Yahoo"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(Source.is_in(namespace_with_begin.sources)) - - -def test_extract_when_given_filename_uses_filename_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.filenames = ["file.txt", "txt.file"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - FileName.is_in(namespace_with_begin.filenames) - ) - - -def test_extract_when_given_filepath_uses_filepath_filter(logger, namespace_with_begin, extractor): - namespace_with_begin.filepaths = ["/path/to/file.txt", "path2"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - FilePath.is_in(namespace_with_begin.filepaths) - ) - - -def test_extract_when_given_process_owner_uses_process_owner_filter( - logger, namespace_with_begin, extractor -): - namespace_with_begin.process_owners = ["test.testerson", "another"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - ProcessOwner.is_in(namespace_with_begin.process_owners) - ) - - -def test_extract_when_given_tab_url_uses_process_tab_url_filter( - logger, namespace_with_begin, extractor -): - namespace_with_begin.tab_urls = ["https://www.example.com"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - TabURL.is_in(namespace_with_begin.tab_urls) - ) - - -def test_extract_when_given_exposure_types_uses_exposure_type_is_in_filter( - logger, namespace_with_begin, extractor -): - namespace_with_begin.exposure_types = ["ApplicationRead", "RemovableMedia", "CloudStorage"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - ExposureType.is_in(namespace_with_begin.exposure_types) - ) - - -def test_extract_when_given_include_non_exposure_does_not_include_exposure_type_exists( - mocker, logger, namespace_with_begin, extractor -): - namespace_with_begin.include_non_exposure_events = True - ExposureType.exists = mocker.MagicMock() - extraction_module.extract(logger, namespace_with_begin) - assert not ExposureType.exists.call_count - - -def test_extract_when_not_given_include_non_exposure_includes_exposure_type_exists( - logger, namespace_with_begin, extractor -): - namespace_with_begin.include_non_exposure_events = False - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(ExposureType.exists()) - - -def test_extract_when_given_multiple_search_args_uses_expected_filters( - logger, namespace_with_begin, extractor -): - namespace_with_begin.filepaths = ["/path/to/file.txt"] - namespace_with_begin.process_owners = ["test.testerson", "flag.flagerson"] - namespace_with_begin.tab_urls = ["https://www.example.com"] - extraction_module.extract(logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - FilePath.is_in(namespace_with_begin.filepaths) - ) - assert str(extractor.extract.call_args[0][2]) == str( - ProcessOwner.is_in(namespace_with_begin.process_owners) - ) - assert str(extractor.extract.call_args[0][3]) == str( - TabURL.is_in(namespace_with_begin.tab_urls) - ) - - -def test_extract_when_given_include_non_exposure_and_exposure_types_causes_exit( - logger, namespace_with_begin, extractor -): - namespace_with_begin.exposure_types = ["ApplicationRead", "RemovableMedia", "CloudStorage"] - namespace_with_begin.include_non_exposure_events = True - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace_with_begin) - - -def test_extract_when_creating_sdk_throws_causes_exit(logger, extractor, namespace, mock_42): - def side_effect(): - raise Exception() - - mock_42.side_effect = side_effect - with pytest.raises(SystemExit): - extraction_module.extract(logger, namespace) - - -def test_extract_when_global_variable_is_true_and_is_interactive_prints_error( - mocker, logger, namespace_with_begin, extractor -): - mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") - mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") - mock_is_interactive_function.return_value = True - extraction_module._EXCEPTIONS_OCCURRED = True - extraction_module.extract(logger, namespace_with_begin) - assert mock_error_printer.call_count - - -def test_extract_when_global_variable_is_true_and_not_is_interactive_does_not_print_error( - mocker, logger, namespace_with_begin, extractor -): - mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") - mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") - mock_is_interactive_function.return_value = False - extraction_module._EXCEPTIONS_OCCURRED = True - extraction_module.extract(logger, namespace_with_begin) - assert not mock_error_printer.call_count - - -def test_extract_when_global_variable_is_false_and_is_interactive_does_not_print_error( - mocker, logger, namespace_with_begin, extractor -): - mock_error_printer = mocker.patch("code42cli.securitydata.extraction.print_error") - mock_is_interactive_function = mocker.patch("code42cli.securitydata.extraction.is_interactive") - mock_is_interactive_function.return_value = True - extraction_module._EXCEPTIONS_OCCURRED = False - extraction_module.extract(logger, namespace_with_begin) - assert not mock_error_printer.call_count - - -def test_when_sdk_raises_exception_global_variable_gets_set( - mocker, logger, namespace_with_begin, mock_42 -): - extraction_module._EXCEPTIONS_OCCURRED = False - mock_sdk = mocker.MagicMock() - - # For ease - mock = mocker.patch("code42cli.securitydata.extraction.is_interactive") - mock.return_value = False - - def sdk_side_effect(self, *args): - raise Exception() - - mock_sdk.security.search_file_events.side_effect = sdk_side_effect - mock_42.return_value = mock_sdk - - mocker.patch( - "c42eventextractor.extractors.FileEventExtractor._verify_compatibility_of_filter_groups" - ) - - extraction_module.extract(logger, namespace_with_begin) - assert extraction_module._EXCEPTIONS_OCCURRED - - -def test_extract_when_no_results_are_found_prints_error_to_stderr( - mocker, logger, namespace_with_begin, extractor -): - mock_error = mocker.patch("code42cli.securitydata.extraction.print_to_stderr") - extraction_module._TOTAL_EVENTS = 0 - extraction_module.extract(logger, namespace_with_begin) - assert mock_error.call_count - - -def test_extract_when_results_are_found_does_not_print_error_to_stderr( - mocker, logger, namespace_with_begin, extractor -): - mock_error = mocker.patch("code42cli.securitydata.extraction.print_to_stderr") - extraction_module._TOTAL_EVENTS = 1 - extraction_module.extract(logger, namespace_with_begin) - assert not mock_error.call_count diff --git a/tests/test_args.py b/tests/test_args.py new file mode 100644 index 000000000..6e37d7a7e --- /dev/null +++ b/tests/test_args.py @@ -0,0 +1,72 @@ +from code42cli.args import ArgConfig, ArgConfigCollection + + +class TestArgConfig(object): + def test_param_names_accessible_on_options_list(self): + arg_config = ArgConfig("-t", "--test") + names = arg_config.settings["options_list"] + assert len(names) == 2 + assert names[0] == "-t" + assert names[1] == "--test" + + def test_action_accessible(self): + arg_config = ArgConfig("-t", "--test", action="store") + assert arg_config.settings["action"] == "store" + + def test_choices_accessible(self): + choices = ["one", "two"] + arg_config = ArgConfig("-t", "--test", choices=choices) + assert arg_config.settings["choices"] == choices + + def test_default_accessible(self): + default = "testdefault" + arg_config = ArgConfig("-t", "--test", default=default) + assert arg_config.settings["default"] == default + + def test_help_accessible(self): + help = "testhelp" + arg_config = ArgConfig("-t", "--test", help=help) + assert arg_config.settings["help"] == help + + def test_nargs_accessible(self): + nargs = "+" + arg_config = ArgConfig("-t", "--test", nargs=nargs) + assert arg_config.settings["nargs"] == nargs + + def test_set_choices_modifies_choices(self): + choices = ["something", "another"] + arg_config = ArgConfig("-t", "--test") + arg_config.set_choices(choices) + assert arg_config.settings["choices"] == choices + + def test_set_help_modifies_help(self): + help = "testhelp" + arg_config = ArgConfig("-t", "--test") + arg_config.set_help(help) + assert arg_config.settings["help"] == help + + def test_add_short_option_name_modifies_options_list(self): + arg_config = ArgConfig("--test") + arg_config.add_short_option_name("-x") + assert "-x" in arg_config.settings["options_list"] + + +class TestArgConfigCollection(object): + def test_add_adds_arg_config(self): + arg_config = ArgConfig() + coll = ArgConfigCollection() + coll.append("test", arg_config) + assert coll.arg_configs["test"] == arg_config + + def test_extends_adds_multiple_arg_configs(self): + configs = {} + arg_config1 = ArgConfig() + arg_config2 = ArgConfig() + configs["one"] = arg_config1 + configs["two"] = arg_config2 + + coll = ArgConfigCollection() + coll.extend(configs) + assert len(coll.arg_configs) == 2 + assert coll.arg_configs["one"] == arg_config1 + assert coll.arg_configs["two"] == arg_config2 diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 000000000..3824eb4d7 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,215 @@ +import pytest +from py42.sdk import SDKClient + +from code42cli.args import ArgConfig +from code42cli.commands import Command, DictObject +from code42cli.profile import Code42Profile +from .conftest import ( + func_keyword_args, + func_mixed_args, + func_positional_args, + func_with_args, + func_with_sdk, +) + +subcommand1 = Command("sub1", "sub1 desc", "sub1 usage") +subcommand2 = Command("sub2", "sub2 desc", "sub2 usage") +subcommand3 = Command("sub3", "sub3 desc", "sub3 usage") + + +def subcommand_loader(): + return [subcommand1, subcommand2, subcommand3] + + +def arg_customizer(arg_collection): + arg_collection.append("success", ArgConfig("--success")) + + +@pytest.fixture +def mock_profile_reader(mocker): + return mocker.patch("code42cli.profile.get_profile") + + +@pytest.fixture +def mock_sdk_client(mocker, mock_42): + client = mocker.MagicMock(spec=SDKClient) + mock_42.return_value = client + return client + + +class TestCommand(object): + def test_name(self): + command = Command("test", "test desc", "test usage") + assert command.name == "test" + + def test_description(self): + command = Command("test", "test desc", "test usage") + assert command.description == "test desc" + + def test_usage(self): + command = Command("test", "test desc", "test usage") + assert command.usage == "test usage" + + def test_load_subcommands_makes_subcommands_accessible(self): + command = Command("test", "test desc", "test usage", subcommand_loader=subcommand_loader) + command.load_subcommands() + assert len(command.subcommands) == 3 + assert subcommand1 in command.subcommands + assert subcommand2 in command.subcommands + assert subcommand3 in command.subcommands + + def test_load_subcommands_when_no_loader_does_nothing(self): + command = Command("test", "test desc", "test usage") + command.load_subcommands() + assert not len(command.subcommands) + + def test_get_arg_configs_when_no_func_returns_empty_collection(self): + command = Command("test", "test desc", "test usage") + coll = command.get_arg_configs() + assert not coll + + def test_get_arg_configs_calls_arg_customizer_if_present(self): + command = Command("test", "test desc", "test usage", func_keyword_args, arg_customizer) + coll = command.get_arg_configs() + assert "success" in coll + + def test_get_arg_configs_when_keyword_args_returns_expected_collection(self): + command = Command("test", "test desc", "test usage", func_keyword_args) + coll = command.get_arg_configs() + assert "--one" in coll["one"].settings["options_list"] + assert "--two" in coll["two"].settings["options_list"] + assert "--three" in coll["three"].settings["options_list"] + assert "--default" in coll["default"].settings["options_list"] + + def test_get_arg_configs_when_keyword_args_has_defaults_set(self): + command = Command("test", "test desc", "test usage", func_keyword_args) + coll = command.get_arg_configs() + assert coll["default"].settings["default"] == "testdefault" + + def test_get_arg_configs_when_keyword_args_with_list_defaults_has_nargs_set(self): + command = Command("test", "test desc", "test usage", func_keyword_args) + coll = command.get_arg_configs() + assert coll["nargstest"].settings["nargs"] == "+" + + def test_get_arg_configs_when_positional_args_returns_expected_collection(self): + command = Command("test", "test desc", "test usage", func_positional_args) + coll = command.get_arg_configs() + assert "one" in coll["one"].settings["options_list"] + assert "two" in coll["two"].settings["options_list"] + assert "three" in coll["three"].settings["options_list"] + + def test_get_arg_configs_when_mixed_args_returns_expected_collection(self): + command = Command("test", "test desc", "test usage", func_mixed_args) + coll = command.get_arg_configs() + assert "one" in coll["one"].settings["options_list"] + assert "two" in coll["two"].settings["options_list"] + assert "--three" in coll["three"].settings["options_list"] + assert "--four" in coll["four"].settings["options_list"] + + def test_get_arg_configs_when_handler_with_sdk_includes_profile_and_debug(self): + command = Command("test", "test desc", "test usage", func_with_sdk) + coll = command.get_arg_configs() + assert "one" in coll["one"].settings["options_list"] + assert "two" in coll["two"].settings["options_list"] + assert "--three" in coll["three"].settings["options_list"] + assert "--four" in coll["four"].settings["options_list"] + assert "--profile" in coll["profile"].settings["options_list"] + assert "--debug" in coll["debug"].settings["options_list"] + assert not coll.get("sdk") + + def test_get_arg_configs_when_handler_with_args_excludes_args(self): + command = Command("test", "test desc", "test usage", func_with_args) + coll = command.get_arg_configs() + assert not coll.get("args") + + def test_call_when_keyword_args_passes_expected_values(self, mocker): + def test_handler(one=None, two=None, three=None): + if one == "testone" and two == "testtwo" and three == "testthree": + return "success" + + command = Command("test", "test desc", "test usage", test_handler) + kvps = {"one": "testone", "two": "testtwo", "three": "testthree"} + kvps = DictObject(kvps) + assert command(kvps) == "success" + + def test_call_when_positional_args_passes_expected_values(self, mocker): + def test_handler(one, two, three): + if one == "testone" and two == "testtwo" and three == "testthree": + return "success" + + command = Command("test", "test desc", "test usage", test_handler) + kvps = {"one": "testone", "two": "testtwo", "three": "testthree"} + kvps = DictObject(kvps) + assert command(kvps) == "success" + + def test_call_when_both_positional_and_optional_args_passes_expected_values(self, mocker): + def test_handler(one, two, three=None, four=None): + if ( + one == "testone" + and two == "testtwo" + and three == "testthree" + and four == "testfour" + ): + return "success" + + command = Command("test", "test desc", "test usage", test_handler) + kvps = {"one": "testone", "two": "testtwo", "three": "testthree", "four": "testfour"} + kvps = DictObject(kvps) + assert command(kvps) == "success" + + def test_call_when_handler_with_sdk_passes_expected_values( + self, mocker, mock_sdk_client, mock_profile_reader + ): + def test_handler(sdk, one, two, three=None, four=None): + if ( + sdk == mock_sdk_client + and one == "testone" + and two == "testtwo" + and three == "testthree" + and four == "testfour" + ): + return "success" + + command = Command("test", "test desc", "test usage", test_handler) + kvps = {"one": "testone", "two": "testtwo", "three": "testthree", "four": "testfour"} + kvps = DictObject(kvps) + assert command(kvps) == "success" + + def test_call_when_handler_with_sdk_and_profile_passes_expected_values( + self, mocker, mock_sdk_client, mock_profile_reader + ): + mock_profile = mocker.MagicMock(spec=Code42Profile) + mock_profile_reader.return_value = mock_profile + + def test_handler(sdk, profile, one, two, three=None, four=None): + if ( + sdk == mock_sdk_client + and profile == mock_profile + and one == "testone" + and two == "testtwo" + and three == "testthree" + and four == "testfour" + ): + return "success" + + command = Command("test", "test desc", "test usage", test_handler) + kvps = {"one": "testone", "two": "testtwo", "three": "testthree", "four": "testfour"} + kvps = DictObject(kvps) + assert command(kvps) == "success" + + def test_call_when_handler_with_args_calls_with_single_obj_with_expected_values(self): + def test_handler(args): + if args.one == "testone" and args.two == "testtwo" and args.three == "testthree": + return "success" + + command = Command("test", "test desc", "test usage", test_handler, use_single_arg_obj=True) + kvps = {"one": "testone", "two": "testtwo", "three": "testthree"} + kvps = DictObject(kvps) + assert command(kvps) == "success" + + def test_call_func_with_no_handler_and_print_help_prints_help(self): + def dummy_print_help(): + return "success" + + command = Command("test", "test desc", "test usage") + assert command(help_func=dummy_print_help) == "success" diff --git a/tests/profile/test_config.py b/tests/test_config.py similarity index 68% rename from tests/profile/test_config.py rename to tests/test_config.py index 2a4b2105b..c78ba8d34 100644 --- a/tests/profile/test_config.py +++ b/tests/test_config.py @@ -1,11 +1,10 @@ from __future__ import with_statement -from configparser import ConfigParser - import pytest +from configparser import ConfigParser -from code42cli.profile.config import ConfigAccessor -from ..conftest import MockSection +from code42cli.config import ConfigAccessor +from .conftest import MockSection @pytest.fixture @@ -77,64 +76,6 @@ def test_get_all_profiles_excludes_internal_section(self, mock_config_parser): if p.name == "Internal": assert False - def test_set_username_marks_as_complete_if_ready(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal", "ProfileA"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile = create_mock_profile_object("ProfileA", "www.example.com", None) - mock_internal = create_internal_object(False) - setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) - accessor.set_username("TestUser", "ProfileA") - assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == "ProfileA" - assert mock_internal[ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE] - - def test_set_username_does_not_mark_as_complete_if_not_have_authority(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal", "ProfileA"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile = create_mock_profile_object("ProfileA", None, None) - mock_internal = create_internal_object(False) - setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) - accessor.set_username("TestUser", "ProfileA") - assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == ConfigAccessor.DEFAULT_VALUE - assert not mock_internal[ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE] - - def test_set_username_saves(self, mock_config_parser, mock_saver): - mock_config_parser.sections.return_value = ["Internal", "ProfileA"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile = create_mock_profile_object("ProfileA", "www.example.com", "username") - mock_internal = create_internal_object(True, "ProfileA") - setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) - accessor.set_username("TestUser", "ProfileA") - assert mock_saver.call_count - - def test_set_authority_marks_as_complete_if_ready(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal", "ProfileA"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile = create_mock_profile_object("ProfileA", None, "test.testerson") - mock_internal = create_internal_object(False) - setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) - accessor.set_authority_url("new url", "ProfileA") - assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == "ProfileA" - assert mock_internal[ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE] - - def test_set_authority_does_not_mark_as_complete_if_not_have_username(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal", "ProfileA"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile = create_mock_profile_object("ProfileA", None, None) - mock_internal = create_internal_object(False) - setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) - accessor.set_authority_url("new url", "ProfileA") - assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == ConfigAccessor.DEFAULT_VALUE - assert not mock_internal[ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE] - - def test_set_authority_saves(self, mock_config_parser, mock_saver): - mock_config_parser.sections.return_value = ["Internal", "ProfileA"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile = create_mock_profile_object("ProfileA", None, None) - mock_internal = create_internal_object(True, "ProfileA") - setup_parser_one_profile(mock_profile, mock_internal, mock_config_parser) - accessor.set_authority_url("new url", "ProfileA") - assert mock_saver.call_count - def test_get_all_profiles_returns_profiles_with_expected_values(self, mock_config_parser): mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] accessor = ConfigAccessor(mock_config_parser) @@ -196,10 +137,83 @@ def side_effect(item): accessor.switch_default_profile("ProfileB") assert mock_saver.call_count - def test_create_profile_if_not_exists_when_given_default_name_does_not_create( - self, mock_config_parser + def test_switch_default_profile_outputs_confirmation( + self, capsys, mock_config_parser, mock_saver ): + mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] + accessor = ConfigAccessor(mock_config_parser) + mock_profile_a = create_mock_profile_object("ProfileA", "test", "test") + mock_profile_b = create_mock_profile_object("ProfileB", "test", "test") + + mock_internal = create_internal_object(True, "ProfileA") + + def side_effect(item): + if item == "ProfileA": + return mock_profile_a + elif item == "ProfileB": + return mock_profile_b + elif item == "Internal": + return mock_internal + + mock_config_parser.__getitem__.side_effect = side_effect + accessor.switch_default_profile("ProfileB") + capture = capsys.readouterr() + assert "set as the default profile" in capture.out + + def test_create_profile_when_given_default_name_does_not_create(self, mock_config_parser): mock_config_parser.sections.return_value = ["Internal", "ProfileA"] accessor = ConfigAccessor(mock_config_parser) with pytest.raises(Exception): - accessor.create_profile_if_not_exists(ConfigAccessor.DEFAULT_VALUE) + accessor.create_profile(ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False) + + def test_create_profile_when_no_default_profile_sets_default( + self, mocker, mock_config_parser, mock_saver + ): + mock_config_parser.sections.return_value = ["Internal"] + mock_profile = create_mock_profile_object("ProfileA", None, None) + mock_internal = create_internal_object(False) + mock_internal["default_profile_is_complete"] = "False" + setup_parser_one_profile(mock_internal, mock_internal, mock_config_parser) + accessor = ConfigAccessor(mock_config_parser) + accessor.switch_default_profile = mocker.MagicMock() + + accessor.create_profile("ProfileA", "example.com", "bar", False) + assert accessor.switch_default_profile.call_count == 1 + + def test_create_profile_when_has_default_profile_does_not_set_default( + self, mocker, mock_config_parser, mock_saver + ): + mock_config_parser.sections.return_value = ["Internal"] + mock_profile = create_mock_profile_object("ProfileA", None, None) + mock_internal = create_internal_object(True, "ProfileA") + setup_parser_one_profile(mock_internal, mock_internal, mock_config_parser) + accessor = ConfigAccessor(mock_config_parser) + accessor.switch_default_profile = mocker.MagicMock() + + accessor.create_profile("ProfileA", "example.com", "bar", False) + assert not accessor.switch_default_profile.call_count + + def test_create_profile_when_not_existing_saves(self, mock_config_parser, mock_saver): + mock_config_parser.sections.return_value = ["Internal"] + mock_profile = create_mock_profile_object("ProfileA", None, None) + mock_internal = create_internal_object(False) + mock_internal["default_profile_is_complete"] = "False" + setup_parser_one_profile(mock_internal, mock_internal, mock_config_parser) + accessor = ConfigAccessor(mock_config_parser) + + accessor.create_profile("ProfileA", "example.com", "bar", False) + assert mock_saver.call_count + + def test_create_profile_when_not_existing_outputs_confirmation( + self, capsys, mock_config_parser, mock_saver + ): + mock_config_parser.sections.return_value = ["Internal"] + mock_profile = create_mock_profile_object("ProfileA", None, None) + mock_internal = create_internal_object(False) + mock_internal["default_profile_is_complete"] = "False" + setup_parser_one_profile(mock_internal, mock_internal, mock_config_parser) + accessor = ConfigAccessor(mock_config_parser) + + accessor.create_profile("ProfileA", "example.com", "bar", False) + capture = capsys.readouterr() + assert "Successfully saved" in capture.out diff --git a/tests/test_invoker.py b/tests/test_invoker.py new file mode 100644 index 000000000..39ac847cb --- /dev/null +++ b/tests/test_invoker.py @@ -0,0 +1,59 @@ +import pytest + +from code42cli.commands import Command +from code42cli.invoker import CommandInvoker +from code42cli.parser import ArgumentParserError, CommandParser + + +def dummy_method(one, two, three=None): + if three == "test": + return "success" + + +def load_subcommands(*args): + return [ + Command("testsub1", "the subdesc1", subcommand_loader=load_sub_subcommands), + Command("testsub2", "the subdesc2"), + ] + + +def load_sub_subcommands(): + return [Command("inner1", "the innerdesc1", handler=dummy_method)] + + +@pytest.fixture +def mock_parser(mocker): + return mocker.MagicMock(spec=CommandParser) + + +class TestCommandInvoker(object): + def test_run_top_cmd(self, mock_parser): + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + invoker = CommandInvoker(cmd, mock_parser) + invoker.run([]) + mock_parser.prepare_cli_help.assert_called_once_with(cmd) + + def test_run_nested_cmd_calls_prepare_command(self, mock_parser): + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + invoker = CommandInvoker(cmd, mock_parser) + invoker.run(["testsub1", "inner1", "one", "two", "--three", "test"]) + subcommand = cmd.subcommands[0].subcommands[0] + mock_parser.prepare_command.assert_called_once_with(subcommand, ["testsub1", "inner1"]) + + def test_run_nested_cmd_calls_successfully(self, mocker, mock_parser): + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + parsed_args = mocker.MagicMock() + mock_parser.parse_args.return_value = parsed_args + invoker = CommandInvoker(cmd, mock_parser) + invoker.run(["testsub1", "inner1", "one", "two", "--three", "test"]) + assert parsed_args.func.call_count + + def test_run_nested_cmd_when_raises_argumentparsererror_prints_help(self, mocker, mock_parser): + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + mock_parser.parse_args.side_effect = ArgumentParserError() + mock_subparser = mocker.MagicMock() + mock_parser.prepare_command.return_value = mock_subparser + invoker = CommandInvoker(cmd, mock_parser) + with pytest.raises(SystemExit): + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + assert mock_subparser.print_help.call_count diff --git a/tests/test_main.py b/tests/test_main.py index b64e1fd68..a8945af91 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,68 +1,26 @@ -import pytest - +# run the help commands on some stuff to prove stuff loads from code42cli.main import main -@pytest.fixture -def profile(mocker): - return mocker.patch("code42cli.profile.profile.init") - - -@pytest.fixture -def checkpoint_clearer(mocker): - return mocker.patch("code42cli.securitydata.subcommands.clear_checkpoint.init") - - -@pytest.fixture -def printer(mocker): - return mocker.patch("code42cli.securitydata.subcommands.print_out.init") - - -@pytest.fixture -def writer(mocker): - return mocker.patch("code42cli.securitydata.subcommands.write_to.init") - - -@pytest.fixture -def sender(mocker): - return mocker.patch("code42cli.securitydata.subcommands.send_to.init") - - -@pytest.fixture(autouse=True) -def arg_parser(mocker): - return mocker.patch("argparse.ArgumentParser.parse_args") - - -def test_main_inits_profile(profile): - main() - assert profile.call_count == 1 - - -def test_main_inits_clear_cursor(checkpoint_clearer): - main() - assert checkpoint_clearer.call_count == 1 - - -def test_main_inits_print(printer): - main() - assert printer.call_count == 1 - - -def test_main_inits_write_to(writer): - main() - assert writer.call_count == 1 - - -def test_main_inits_send_to(sender): - main() - assert sender.call_count == 1 - - -def test_main_calls_subcommand_with_parsed_args(mocker, arg_parser, namespace): - arg_parser.return_value = namespace - namespace.format = "TEST" - namespace.exposure_types = ["EXPOSURE"] - namespace.func = mocker.MagicMock() - main() - actual_args = namespace.func.call_args[0][0] - assert actual_args == namespace +def test_securitydata_commands_load(capsys, mocker): + mocker.patch("sys.argv", ["code42", "securitydata", "print", "-h"]) + success = False + try: + main() + except SystemExit: + success = True + capture = capsys.readouterr() + assert "print" in capture.out + assert success + + +def test_profile_commands_load(capsys, mocker): + mocker.patch("sys.argv", ["code42", "profile", "show", "-h"]) + success = False + try: + main() + except SystemExit: + success = True + capture = capsys.readouterr() + assert "show" in capture.out + assert success diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 000000000..069da5a09 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,115 @@ +import pytest + +from code42cli.commands import Command +from code42cli.parser import ArgumentParserError, CommandParser + + +def dummy_method(): + return "success" + + +def dummy_method_required_args(one, two): + return "success" + + +def dummy_method_optional_args(one=None, two=None): + if one == "onetest" and two == "twotest": + return "success" + + +def load_subcommands(*args): + return [Command("testsub1", "the subdesc1"), Command("testsub2", "the subdesc2")] + + +class TestCommandParser(object): + def test_prepare_command_when_no_args(self): + cmd = Command("runnable", "the desc", "the usage", handler=dummy_method) + parts = ["runnable"] + parser = CommandParser() + parser.prepare_command(cmd, parts) + parsed_args = parser.parse_args(["runnable"]) + assert parsed_args.func(parsed_args) == "success" + + def test_prepare_command_when_no_args_and_nested(self): + cmd = Command("runnable", "the desc", "the usage", handler=dummy_method) + parts = ["subgroup", "runnable"] + parser = CommandParser() + parser.prepare_command(cmd, parts) + parsed_args = parser.parse_args(["subgroup", "runnable"]) + assert parsed_args.func(parsed_args) == "success" + + def test_prepare_command_when_required_args(self): + cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_required_args) + parts = ["runnable"] + parser = CommandParser() + parser.prepare_command(cmd, parts) + parsed_args = parser.parse_args(["runnable", "one", "two"]) + assert parsed_args.func(parsed_args) == "success" + + def test_prepare_command_when_required_args_help_outputs_help(self, capsys): + cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_required_args) + parts = ["runnable"] + parser = CommandParser() + parser.prepare_command(cmd, parts) + success = False + try: + parsed_args = parser.parse_args(["runnable", "-h"]) + except SystemExit: + success = True + captured = capsys.readouterr() + assert "the desc" in captured.out + assert "one" in captured.out + assert "two" in captured.out + assert success + + def test_prepare_command_when_optional_args(self): + cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_optional_args) + parts = ["runnable"] + parser = CommandParser() + parser.prepare_command(cmd, parts) + parsed_args = parser.parse_args(["runnable", "--one", "onetest", "--two", "twotest"]) + assert parsed_args.func(parsed_args) == "success" + + def test_prepare_command_when_optional_args_help_outputs_help(self, capsys): + cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_optional_args) + parts = ["runnable"] + parser = CommandParser() + parser.prepare_command(cmd, parts) + success = False + try: + parsed_args = parser.parse_args(["runnable", "-h"]) + except SystemExit: + success = True + captured = capsys.readouterr() + assert "the desc" in captured.out + assert "--one" in captured.out + assert "--two" in captured.out + assert success + + def test_prepare_command_when_required_args_and_args_missing_throws(self, capsys): + cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_required_args) + parts = ["runnable"] + parser = CommandParser() + parser.prepare_command(cmd, parts) + with pytest.raises(ArgumentParserError): + parsed_args = parser.parse_args(["runnable"]) + + def test_prepare_command_when_extra_args_throws(self): + cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_optional_args) + parts = ["runnable"] + parser = CommandParser() + parser.prepare_command(cmd, parts) + with pytest.raises(ArgumentParserError): + parsed_args = parser.parse_args(["runnable", "--invalid"]) + + def test_prepare_cli_help_outputs_group_info(self, capsys): + cmd = Command("runnable", "the desc", "the usage", subcommand_loader=load_subcommands) + parser = CommandParser() + parser.prepare_cli_help(cmd) + parser.parse_args([]) + parser.print_help() + captured = capsys.readouterr() + assert "the subdesc1" in captured.out + assert "testsub1" in captured.out + assert "the subdesc2" in captured.out + assert "testsub2" in captured.out diff --git a/tests/test_password.py b/tests/test_password.py new file mode 100644 index 000000000..07c96bdf7 --- /dev/null +++ b/tests/test_password.py @@ -0,0 +1,100 @@ +import pytest + +import code42cli.password as password + +_USERNAME = "test.username" + + +@pytest.fixture +def keyring_password_getter(mocker): + return mocker.patch("keyring.get_password") + + +@pytest.fixture(autouse=True) +def keyring_password_setter(mocker): + return mocker.patch("keyring.set_password") + + +@pytest.fixture(autouse=True) +def get_keyring(mocker): + mock = mocker.patch("keyring.get_keyring") + mock.return_value.priority = 10 + return mock + + +@pytest.fixture +def getpass_function(mocker): + return mocker.patch("code42cli.password.getpass") + + +@pytest.fixture +def user_agreement(mocker): + mock = mocker.patch("code42cli.password.does_user_agree") + mock.return_value = True + return mocker + + +@pytest.fixture +def user_disagreement(mocker): + mock = mocker.patch("code42cli.password.does_user_agree") + mock.return_value = False + return mocker + + +def test_get_stored_password_when_given_profile_name_gets_profile_for_that_name( + profile, keyring_password_getter +): + profile.name = "foo" + profile.username = "bar" + service_name = "code42cli::{}".format(profile.name) + password.get_stored_password(profile) + keyring_password_getter.assert_called_once_with(service_name, profile.username) + + +def test_get_stored_password_returns_expected_password( + profile, keyring_password_getter, keyring_password_setter +): + keyring_password_getter.return_value = "already stored password 123" + assert password.get_stored_password(profile) == "already stored password 123" + + +def test_set_password_uses_expected_service_name_username_and_password( + profile, keyring_password_setter, keyring_password_getter +): + keyring_password_getter.return_value = "test_password" + profile.name = "profile_name" + profile.username = "test.username" + password.set_password(profile, "test_password") + expected_service_name = "code42cli::profile_name" + keyring_password_setter.assert_called_once_with( + expected_service_name, profile.username, "test_password" + ) + + +def test_set_password_when_using_file_fallback_and_user_accepts_saves_password( + profile, keyring_password_setter, keyring_password_getter, get_keyring, user_agreement +): + keyring_password_getter.return_value = "test_password" + profile.name = "profile_name" + profile.username = "test.username" + password.set_password(profile, "test_password") + expected_service_name = "code42cli::profile_name" + keyring_password_setter.assert_called_once_with( + expected_service_name, profile.username, "test_password" + ) + + +def test_set_password_when_using_file_fallback_and_user_rejects_does_not_saves_password( + profile, keyring_password_setter, get_keyring, user_disagreement +): + get_keyring.return_value.priority = 0.5 + keyring_password_getter.return_value = "test_password" + profile.name = "profile_name" + profile.username = "test.username" + password.set_password(profile, "test_password") + assert not keyring_password_setter.call_count + + +def test_prompt_for_password_calls_getpass(getpass_function): + password.get_password_from_prompt() + assert getpass_function.call_count diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 000000000..d3553127a --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,148 @@ +import pytest + +import code42cli.profile as cliprofile +from code42cli.config import ConfigAccessor +from .conftest import MockSection, create_mock_profile + + +@pytest.fixture +def config_accessor(mocker): + mock = mocker.MagicMock(spec=ConfigAccessor, name="Config Accessor") + attr = mocker.patch("code42cli.profile.config_accessor", mock) + return attr + + +@pytest.fixture +def password_setter(mocker): + return mocker.patch("code42cli.password.set_password") + + +@pytest.fixture +def password_getter(mocker): + return mocker.patch("code42cli.password.get_stored_password") + + +class TestCode42Profile(object): + def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter): + password_getter.return_value = None + mock_getpass = mocker.patch("code42cli.password.get_password_from_prompt") + mock_getpass.return_value = "Test Password" + actual = create_mock_profile().get_password() + assert actual == "Test Password" + + def test_get_password_return_password_from_password_get_password(self, password_getter): + password_getter.return_value = "Test Password" + actual = create_mock_profile().get_password() + assert actual == "Test Password" + + def test_authority_url_returns_expected_value(self): + mock_profile = create_mock_profile() + assert mock_profile.authority_url == "example.com" + + def test_name_returns_expected_value(self): + mock_profile = create_mock_profile() + assert mock_profile.name == "Test Profile Name" + + def test_username_returns_expected_value(self): + mock_profile = create_mock_profile() + assert mock_profile.username == "foo" + + def test_ignore_ssl_errors_returns_expected_value(self): + mock_profile = create_mock_profile() + assert mock_profile.ignore_ssl_errors == True + + +def test_get_profile_returns_expected_profile(config_accessor): + mock_section = MockSection("testprofilename") + config_accessor.get_profile.return_value = mock_section + profile = cliprofile.get_profile("testprofilename") + assert profile.name == "testprofilename" + + +def test_get_profile_when_config_accessor_throws_exits(config_accessor): + config_accessor.get_profile.side_effect = Exception() + with pytest.raises(SystemExit): + profile = cliprofile.get_profile("testprofilename") + + +def test_default_profile_exists_when_exists_returns_true(config_accessor): + mock_section = MockSection("testprofilename") + config_accessor.get_profile.return_value = mock_section + assert cliprofile.default_profile_exists() + + +def test_default_profile_exists_when_not_exists_returns_false(config_accessor): + mock_section = MockSection("__DEFAULT__") + config_accessor.get_profile.return_value = mock_section + assert not cliprofile.default_profile_exists() + + +def test_profile_exists_when_exists_returns_true(config_accessor): + mock_section = MockSection("testprofilename") + config_accessor.get_profile.return_value = mock_section + assert cliprofile.profile_exists("testprofilename") + + +def test_profile_exists_when_not_exists_returns_false(config_accessor): + config_accessor.get_profile.side_effect = Exception() + assert not cliprofile.profile_exists("idontexist") + + +def test_switch_default_profile_switches_to_expected_profile(config_accessor): + cliprofile.switch_default_profile("switchtome") + config_accessor.switch_default_profile.assert_called_once_with("switchtome") + + +def test_create_profile_uses_expected_profile_values(config_accessor): + profile_name = "profilename" + server = "server" + username = "username" + ssl_errors_disabled = True + cliprofile.create_profile(profile_name, server, username, ssl_errors_disabled) + config_accessor.create_profile.assert_called_once_with( + profile_name, server, username, ssl_errors_disabled + ) + + +def test_get_all_profiles_returns_expected_profile_list(config_accessor): + config_accessor.get_all_profiles.return_value = [ + create_mock_profile("one"), + create_mock_profile("two"), + ] + profiles = cliprofile.get_all_profiles() + assert len(profiles) == 2 + assert profiles[0].name == "one" + assert profiles[1].name == "two" + + +def test_get_stored_password_returns_expected_password(config_accessor, password_getter): + mock_section = MockSection("testprofilename") + config_accessor.get_profile.return_value = mock_section + test_profile = "testprofilename" + password_getter.return_value = "testpassword" + assert cliprofile.get_stored_password("testprofilename") == "testpassword" + + +def test_get_stored_password_uses_expected_profile_name(config_accessor, password_getter): + mock_section = MockSection("testprofilename") + config_accessor.get_profile.return_value = mock_section + test_profile = "testprofilename" + password_getter.return_value = "testpassword" + cliprofile.get_stored_password(test_profile) + assert password_getter.call_args[0][0].name == test_profile + + +def test_set_password_uses_expected_profile_name(config_accessor, password_setter): + mock_section = MockSection("testprofilename") + config_accessor.get_profile.return_value = mock_section + test_profile = "testprofilename" + cliprofile.set_password("newpassword", test_profile) + assert password_setter.call_args[0][0].name == test_profile + + +def test_set_password_uses_expected_password(config_accessor, password_setter): + mock_section = MockSection("testprofilename") + config_accessor.get_profile.return_value = mock_section + test_profile = "testprofilename" + cliprofile.set_password("newpassword", test_profile) + assert password_setter.call_args[0][1] == "newpassword" diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 50de426f5..a4330ca6d 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,6 +1,6 @@ -import pytest import py42.sdk import py42.sdk.settings.debug as debug +import pytest from code42cli.sdk_client import create_sdk, validate_connection from .conftest import create_mock_profile @@ -22,12 +22,22 @@ def side_effect(): def test_create_sdk_when_py42_exception_occurs_causes_exit(error_sdk_factory): profile = create_mock_profile() + + def mock_get_password(): + return "Test Password" + + profile.get_password = mock_get_password with pytest.raises(SystemExit): create_sdk(profile, False) def test_create_sdk_when_told_to_debug_turns_on_debug(mock_sdk_factory): profile = create_mock_profile() + + def mock_get_password(): + return "Test Password" + + profile.get_password = mock_get_password create_sdk(profile, True) assert py42.sdk.settings.debug.level == debug.DEBUG diff --git a/tests/test_util.py b/tests/test_util.py index 15576d62d..df95aa703 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,4 @@ -from code42cli.util import get_url_parts, does_user_agree +from code42cli.util import does_user_agree, get_url_parts def test_get_url_parts_when_given_host_and_port_returns_expected_parts(): diff --git a/tox.ini b/tox.ini index 7f4be5ba0..732642260 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ commands = # -l: show locals in tracebacks # --tb=short: short traceback print mode # --strict: marks not registered in configuration file raise errors - pytest --cov=code42cli --cov-append -v -rsxX -l --tb=short --strict + pytest --cov=code42cli --cov-append -v -rsxX -l --tb=short --strict --disable-pytest-warnings depends = {py27,py35,py36,py37,py38}: clean From e971e38439fd57ee7f62cf7e285c46be970a9a70 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 10 Apr 2020 09:17:32 -0500 Subject: [PATCH 021/349] Fix command name (#26) --- .editorconfig | 4 ++-- src/code42cli/parser.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.editorconfig b/.editorconfig index 236bd6311..87efeb1e6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,9 +6,9 @@ root = true indent_style = space indent_size = 4 insert_final_newline = true -trim_trailing_whitespace = true +trim_trailing_whitespace = false end_of_line = lf charset = utf-8 [*.py] -max_line_length = 100 \ No newline at end of file +max_line_length = 100 diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py index 5e458ad26..6ef7cc1d3 100644 --- a/src/code42cli/parser.py +++ b/src/code42cli/parser.py @@ -92,17 +92,17 @@ def _add_argument(parser, arg_settings): def _get_group_help(command): descriptions = _build_group_command_descriptions(command) output = [] - if not command.name: + name = command.name + if not name: name = u"code42" output.append(BANNER) - output.extend([u" \nAvailable commands in <{}>:".format(command.name), descriptions]) + output.extend([u" \nAvailable commands in <{}>:".format(name), descriptions]) return "\n".join(output) def _build_group_command_descriptions(command): subs = command.subcommands - name = command.name name_width = len(max([cmd.name for cmd in subs], key=len)) lines = [u" {} - {}".format(cmd.name.ljust(name_width), cmd.description) for cmd in subs] return u"\n".join(lines) From 3b763c140ec2c5376618f64b23fd51bf8ed980ec Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Fri, 10 Apr 2020 09:51:43 -0500 Subject: [PATCH 022/349] Bugfix/fix arg order py2 (#27) --- CONTRIBUTING.md | 3 ++- src/code42cli/args.py | 3 ++- tests/test_args.py | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c4e326a1..cbd6dcfd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,8 @@ See class documentation on the [Command](src/code42cli/commands.py) class for an 3. For commands that actually are executed (rather than just being groups), you will add a `handler` function as a constructor parameter. This will be the function that you want to execute when your command is run. - * _Positional_ arguments of the handler will automatically become _required_ cli arguments + * _Positional_ arguments of the handler will automatically become _required_ cli arguments. + * The order that the positional arguments should be entered in on the cli is the same as the order in which they appear in the handler. * _Keyword_ arguments of the handler will automatically become _optional_ cli arguments * the cli argument name will be the same as the handler param name except with `_` replaced with `-`, and prefixed with `--` if optional diff --git a/src/code42cli/args.py b/src/code42cli/args.py index b5628e541..d038a593c 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -1,3 +1,4 @@ +from collections import OrderedDict import inspect PROFILE_HELP = u"The name of the Code42 profile use when executing this command." @@ -31,7 +32,7 @@ def add_short_option_name(self, short_name): class ArgConfigCollection(object): def __init__(self): - self._arg_configs = {} + self._arg_configs = OrderedDict() @property def arg_configs(self): diff --git a/tests/test_args.py b/tests/test_args.py index 6e37d7a7e..77f398c4e 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -70,3 +70,23 @@ def test_extends_adds_multiple_arg_configs(self): assert len(coll.arg_configs) == 2 assert coll.arg_configs["one"] == arg_config1 assert coll.arg_configs["two"] == arg_config2 + + def test_arg_configs_are_in_same_order_as_added(self): + arg_config1 = ArgConfig("--test") + arg_config2 = ArgConfig("--test2") + arg_config3 = ArgConfig("--test3") + + coll = ArgConfigCollection() + coll.append("test3", arg_config3) + coll.append("test1", arg_config1) + coll.append("test2", arg_config2) + + for position, key in enumerate(coll.arg_configs): + if position == 0: + assert coll.arg_configs[key] == arg_config3 + + if position == 1: + assert coll.arg_configs[key] == arg_config1 + + if position == 2: + assert coll.arg_configs[key] == arg_config2 From 2c64d16414bf799c7640ab077df00ed8a1c0ace6 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 13 Apr 2020 14:12:35 -0500 Subject: [PATCH 023/349] Casing (#28) --- CHANGELOG.md | 9 ++++ README.md | 30 +++++------ add_high_risk_employee.csv | 1 + src/code42cli/cmds/securitydata/enums.py | 18 ++++--- src/code42cli/cmds/securitydata/extraction.py | 16 +++--- src/code42cli/cmds/securitydata/main.py | 18 +++---- src/code42cli/commands.py | 4 +- src/code42cli/main.py | 2 +- tests/cmds/securitydata/conftest.py | 10 ++-- tests/cmds/securitydata/test_extraction.py | 50 +++++++++---------- tests/conftest.py | 10 ++-- tests/test_main.py | 2 +- 12 files changed, 91 insertions(+), 79 deletions(-) create mode 100644 add_high_risk_employee.csv diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a561514..154151b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Changes + +- `securitydata` renamed to `security-data`. +- From `security-data` related subcommands (such as `print`): + - `--c42username` flag renamed to `--c42-username`. + - `--filename` flag renamed to `--file-name`. + - `--filepath` flag renamed to `--file-path`. + - `--processOwner` flag renamed to `--process-owner` + ### Added - `code42 profile create` command. diff --git a/README.md b/README.md index bc326377f..2c3c8df6c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # The Code42 CLI Use the `code42` command to interact with your Code42 environment. -`code42 securitydata` is a CLI tool for extracting AED events. +`code42 security-data` is a CLI tool for extracting AED events. Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a checkpoint (provided you do not change your query). @@ -45,7 +45,7 @@ You can add multiple profiles with different names and the change the default pr code42 profile use MY_SECOND_PROFILE ``` -When the `--profile` flag is available on other commands, such as those in `securitydata`, it will use that profile instead of the default one. +When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile instead of the default one. To see all your profiles, do: @@ -61,14 +61,14 @@ Using the CLI, you can query for events and send them to three possible destinat To print events to stdout, do: ```bash -code42 securitydata print -b 2020-02-02 +code42 security-data print -b 2020-02-02 ``` Note that `-b` or `--begin` is usually required. To specify a time, do: ```bash -code42 securitydata print -b 2020-02-02 12:51 +code42 security-data print -b 2020-02-02 12:51 ``` Begin date will be ignored if provided on subsequent queries using `-i`. @@ -76,7 +76,7 @@ Begin date will be ignored if provided on subsequent queries using `-i`. Use different format with `-f`: ```bash -code42 securitydata print -b 2020-02-02 -f CEF +code42 security-data print -b 2020-02-02 -f CEF ``` The available formats are CEF, JSON, and RAW-JSON. @@ -84,19 +84,19 @@ The available formats are CEF, JSON, and RAW-JSON. To write events to a file, do: ```bash -code42 securitydata write-to filename.txt -b 2020-02-02 +code42 security-data write-to filename.txt -b 2020-02-02 ``` To send events to a server, do: ```bash -code42 securitydata send-to syslog.company.com -p TCP -b 2020-02-02 +code42 security-data send-to syslog.company.com -p TCP -b 2020-02-02 ``` To only get events that Code42 previously did not observe since you last recorded a checkpoint, use the `-i` flag. ```bash -code42 securitydata send-to syslog.company.com -i +code42 security-data send-to syslog.company.com -i ``` This is only guaranteed if you did not change your query. @@ -104,13 +104,13 @@ This is only guaranteed if you did not change your query. To send events to a server using a specific profile, do: ```bash -code42 securitydata send-to --profile PROFILE_FOR_RECURRING_JOB syslog.company.com -b 2020-02-02 -f CEF -i +code42 security-data send-to --profile PROFILE_FOR_RECURRING_JOB syslog.company.com -b 2020-02-02 -f CEF -i ``` You can also use wildcard for queries, but note, if they are not in quotes, you may get unexpected behavior. ```bash -code42 securitydata print --actor "*" +code42 security-data print --actor "*" ``` Each destination-type subcommand shares query parameters @@ -118,15 +118,15 @@ Each destination-type subcommand shares query parameters - `-t` (exposure types) - `-b` (begin date) - `-e` (end date) -- `--c42username` +- `--c42-username` - `--actor` - `--md5` - `--sha256` - `--source` -- `--filename` -- `--filepath` -- `--processOwner` -- `--tabURL` +- `--file-name` +- `--file-path` +- `--process-owner` +- `--tab-url` - `--include-non-exposure` (does not work with `-t`) - `--advanced-query` (raw JSON query) diff --git a/add_high_risk_employee.csv b/add_high_risk_employee.csv new file mode 100644 index 000000000..4d01d3d5a --- /dev/null +++ b/add_high_risk_employee.csv @@ -0,0 +1 @@ +user_id,risk_factors \ No newline at end of file diff --git a/src/code42cli/cmds/securitydata/enums.py b/src/code42cli/cmds/securitydata/enums.py index 10abfe9f0..af89b06d9 100644 --- a/src/code42cli/cmds/securitydata/enums.py +++ b/src/code42cli/cmds/securitydata/enums.py @@ -44,19 +44,21 @@ def __iter__(self): class SearchArguments(object): + """These string values should match `argparse` stored parameter names. For example, for the + CLI argument `--c42-username`, the string should be `c42_username`.""" ADVANCED_QUERY = u"advanced_query" BEGIN_DATE = u"begin" END_DATE = u"end" EXPOSURE_TYPES = u"type" - C42USERNAME = u"c42username" + C42_USERNAME = u"c42_username" ACTOR = u"actor" MD5 = u"md5" SHA256 = u"sha256" SOURCE = u"source" - FILENAME = u"filename" - FILEPATH = u"filepath" - PROCESS_OWNER = u"processOwner" - TAB_URL = u"tabURL" + FILE_NAME = u"file_name" + FILE_PATH = u"file_path" + PROCESS_OWNER = u"process_owner" + TAB_URL = u"tab_url" INCLUDE_NON_EXPOSURE_EVENTS = u"include_non_exposure" def __iter__(self): @@ -66,13 +68,13 @@ def __iter__(self): self.BEGIN_DATE, self.END_DATE, self.EXPOSURE_TYPES, - self.C42USERNAME, + self.C42_USERNAME, self.ACTOR, self.MD5, self.SHA256, self.SOURCE, - self.FILENAME, - self.FILEPATH, + self.FILE_NAME, + self.FILE_PATH, self.PROCESS_OWNER, self.TAB_URL, self.INCLUDE_NON_EXPOSURE_EVENTS, diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index 238d2b2f7..c0393bb18 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -99,15 +99,15 @@ def _create_filters(args): filters = [] event_timestamp_filter = _get_event_timestamp_filter(args.begin, args.end) not event_timestamp_filter or filters.append(event_timestamp_filter) - not args.c42username or filters.append(DeviceUsername.is_in(args.c42username)) + not args.c42_username or filters.append(DeviceUsername.is_in(args.c42_username)) not args.actor or filters.append(Actor.is_in(args.actor)) not args.md5 or filters.append(MD5.is_in(args.md5)) not args.sha256 or filters.append(SHA256.is_in(args.sha256)) not args.source or filters.append(Source.is_in(args.source)) - not args.filename or filters.append(FileName.is_in(args.filename)) - not args.filepath or filters.append(FilePath.is_in(args.filepath)) - not args.processOwner or filters.append(ProcessOwner.is_in(args.processOwner)) - not args.tabURL or filters.append(TabURL.is_in(args.tabURL)) + not args.file_name or filters.append(FileName.is_in(args.file_name)) + not args.file_path or filters.append(FilePath.is_in(args.file_path)) + not args.process_owner or filters.append(ProcessOwner.is_in(args.process_owner)) + not args.tab_url or filters.append(TabURL.is_in(args.tab_url)) _try_append_exposure_types_filter(filters, args.include_non_exposure, args.type) return filters @@ -175,9 +175,9 @@ def _handle_result(): def _try_append_exposure_types_filter(filters, include_non_exposure_events, exposure_types): - exposure_filter = _create_exposure_type_filter(include_non_exposure_events, exposure_types) - if exposure_filter: - filters.append(exposure_filter) + _exposure_filter = _create_exposure_type_filter(include_non_exposure_events, exposure_types) + if _exposure_filter: + filters.append(_exposure_filter) def _create_exposure_type_filter(include_non_exposure_events, exposure_types): diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index b5e284ae5..495ba8a1d 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -6,8 +6,8 @@ def load_subcommands(): - """Sets up the `securitydata` subcommand with all of its subcommands.""" - usage_prefix = u"code42 securitydata" + """Sets up the `security-data` subcommand with all of its subcommands.""" + usage_prefix = u"code42 security-data" print_func = Command( u"print", @@ -48,7 +48,7 @@ def load_subcommands(): def clear_checkpoint(sdk, profile): """Removes the stored checkpoint that keeps track of the last event you got. - To use, run `code42 securitydata clear-checkpoint`. + To use, run `code42 security-data clear-checkpoint`. This affects `incremental` mode by causing it to behave like it has never been run before. """ FileEventCursorStore(profile.name).replace_stored_insertion_timestamp(None) @@ -121,8 +121,8 @@ def _load_search_args(arg_collection): help=u"Limits events to those with given exposure types. " u"Available choices={0}".format(list(enums.ExposureType())), ), - enums.SearchArguments.C42USERNAME: ArgConfig( - u"--{}".format(enums.SearchArguments.C42USERNAME), + enums.SearchArguments.C42_USERNAME: ArgConfig( + u"--{}".format(enums.SearchArguments.C42_USERNAME), nargs=u"+", help=u"Limits events to endpoint events for these users.", ), @@ -147,13 +147,13 @@ def _load_search_args(arg_collection): nargs=u"+", help=u"Limits events to only those from one of these sources. Example=Gmail.", ), - enums.SearchArguments.FILENAME: ArgConfig( - u"--{}".format(enums.SearchArguments.FILENAME), + enums.SearchArguments.FILE_NAME: ArgConfig( + u"--{}".format(enums.SearchArguments.FILE_NAME), nargs=u"+", help=u"Limits events to file events where the file has one of these names.", ), - enums.SearchArguments.FILEPATH: ArgConfig( - u"--{}".format(enums.SearchArguments.FILEPATH), + enums.SearchArguments.FILE_PATH: ArgConfig( + u"--{}".format(enums.SearchArguments.FILE_PATH), nargs=u"+", help=u"Limits events to file events where the file is located at one of these paths.", ), diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py index 0d9d1dff1..04d27d7a3 100644 --- a/src/code42cli/commands.py +++ b/src/code42cli/commands.py @@ -16,11 +16,11 @@ class Command(object): commands to make it available for use. Args: - name (str): The name of the command. For example, in + name (str or unicode): The name of the command. For example, in `code42 profile show`, "show" is the name, while "profile" is the name of the parent command. - description (str): Descriptive text to be displayed when using -h. + description (str or unicode): Descriptive text to be displayed when using -h. usage (str, optional): A usage example to be displayed when using -h. handler (function, optional): The function to be exectued when the command is run. diff --git a/src/code42cli/main.py b/src/code42cli/main.py index f91cfeb10..ba591c9e6 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -31,7 +31,7 @@ def _load_top_commands(): u"profile", u"For managing Code42 settings.", subcommand_loader=profile.load_subcommands ), Command( - u"securitydata", + u"security-data", u"Tools for getting security related data, such as file events.", subcommand_loader=secmain.load_subcommands, ), diff --git a/tests/cmds/securitydata/conftest.py b/tests/cmds/securitydata/conftest.py index c6e617f63..307eba866 100644 --- a/tests/cmds/securitydata/conftest.py +++ b/tests/cmds/securitydata/conftest.py @@ -56,7 +56,7 @@ def sqlite_connection(mocker): "600", "-e", "2020-02-02", - "--c42username", + "--c42-username", "test.testerson", "--actor", "test.testerson", @@ -66,13 +66,13 @@ def sqlite_connection(mocker): "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "--source", "Gmail", - "--filename", + "--file-name", "file.txt", - "--filepath", + "--file-path", "/path/to/file.txt", - "--processOwner", + "--process-owner", "test.testerson", - "--tabURL", + "--tab-url", "https://example.com", "--include-non-exposure", ] diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index 4320620dc..3dd34bd18 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -92,7 +92,7 @@ def test_extract_when_is_advanced_query_and_has_exposure_types_exits( def test_extract_when_is_advanced_query_and_has_username_exits(sdk, profile, logger, namespace): namespace.advanced_query = "some complex json" - namespace.c42username = ["Someone"] + namespace.c42_username = ["Someone"] with pytest.raises(SystemExit): extraction_module.extract(sdk, profile, logger, namespace) @@ -125,16 +125,16 @@ def test_extract_when_is_advanced_query_and_has_source_exits(sdk, profile, logge extraction_module.extract(sdk, profile, logger, namespace) -def test_extract_when_is_advanced_query_and_has_filename_exits(sdk, profile, logger, namespace): +def test_extract_when_is_advanced_query_and_has_file_name_exits(sdk, profile, logger, namespace): namespace.advanced_query = "some complex json" - namespace.filename = ["test.out"] + namespace.file_name = ["test.out"] with pytest.raises(SystemExit): extraction_module.extract(sdk, profile, logger, namespace) -def test_extract_when_is_advanced_query_and_has_filepath_exits(sdk, profile, logger, namespace): +def test_extract_when_is_advanced_query_and_has_file_path_exits(sdk, profile, logger, namespace): namespace.advanced_query = "some complex json" - namespace.filepath = ["path/to/file"] + namespace.file_path = ["path/to/file"] with pytest.raises(SystemExit): extraction_module.extract(sdk, profile, logger, namespace) @@ -143,14 +143,14 @@ def test_extract_when_is_advanced_query_and_has_process_owner_exits( sdk, profile, logger, namespace ): namespace.advanced_query = "some complex json" - namespace.processOwner = ["someone"] + namespace.process_owner = ["someone"] with pytest.raises(SystemExit): extraction_module.extract(sdk, profile, logger, namespace) def test_extract_when_is_advanced_query_and_has_tab_url_exits(sdk, profile, logger, namespace): namespace.advanced_query = "some complex json" - namespace.tabURL = ["https://www.example.com"] + namespace.tab_url = ["https://www.example.com"] with pytest.raises(SystemExit): extraction_module.extract(sdk, profile, logger, namespace) @@ -349,10 +349,10 @@ def test_extract_when_given_invalid_exposure_type_causes_exit( def test_extract_when_given_username_uses_username_filter( sdk, profile, logger, namespace_with_begin, extractor ): - namespace_with_begin.c42username = ["test.testerson@example.com"] + namespace_with_begin.c42_username = ["test.testerson@example.com"] extraction_module.extract(sdk, profile, logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( - DeviceUsername.is_in(namespace_with_begin.c42username) + DeviceUsername.is_in(namespace_with_begin.c42_username) ) @@ -390,42 +390,42 @@ def test_extract_when_given_source_uses_source_filter( assert str(extractor.extract.call_args[0][1]) == str(Source.is_in(namespace_with_begin.source)) -def test_extract_when_given_filename_uses_filename_filter( +def test_extract_when_given_file_name_uses_file_name_filter( sdk, profile, logger, namespace_with_begin, extractor ): - namespace_with_begin.filename = ["file.txt", "txt.file"] + namespace_with_begin.file_name = ["file.txt", "txt.file"] extraction_module.extract(sdk, profile, logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( - FileName.is_in(namespace_with_begin.filename) + FileName.is_in(namespace_with_begin.file_name) ) -def test_extract_when_given_filepath_uses_filepath_filter( +def test_extract_when_given_file_path_uses_file_path_filter( sdk, profile, logger, namespace_with_begin, extractor ): - namespace_with_begin.filepath = ["/path/to/file.txt", "path2"] + namespace_with_begin.file_path = ["/path/to/file.txt", "path2"] extraction_module.extract(sdk, profile, logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( - FilePath.is_in(namespace_with_begin.filepath) + FilePath.is_in(namespace_with_begin.file_path) ) def test_extract_when_given_process_owner_uses_process_owner_filter( sdk, profile, logger, namespace_with_begin, extractor ): - namespace_with_begin.processOwner = ["test.testerson", "another"] + namespace_with_begin.process_owner = ["test.testerson", "another"] extraction_module.extract(sdk, profile, logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( - ProcessOwner.is_in(namespace_with_begin.processOwner) + ProcessOwner.is_in(namespace_with_begin.process_owner) ) def test_extract_when_given_tab_url_uses_process_tab_url_filter( sdk, profile, logger, namespace_with_begin, extractor ): - namespace_with_begin.tabURL = ["https://www.example.com"] + namespace_with_begin.tab_url = ["https://www.example.com"] extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(TabURL.is_in(namespace_with_begin.tabURL)) + assert str(extractor.extract.call_args[0][1]) == str(TabURL.is_in(namespace_with_begin.tab_url)) def test_extract_when_given_exposure_types_uses_exposure_type_is_in_filter( @@ -458,17 +458,17 @@ def test_extract_when_not_given_include_non_exposure_includes_exposure_type_exis def test_extract_when_given_multiple_search_args_uses_expected_filters( sdk, profile, logger, namespace_with_begin, extractor ): - namespace_with_begin.filepath = ["/path/to/file.txt"] - namespace_with_begin.processOwner = ["test.testerson", "flag.flagerson"] - namespace_with_begin.tabURL = ["https://www.example.com"] + namespace_with_begin.file_path = ["/path/to/file.txt"] + namespace_with_begin.process_owner = ["test.testerson", "flag.flagerson"] + namespace_with_begin.tab_url = ["https://www.example.com"] extraction_module.extract(sdk, profile, logger, namespace_with_begin) assert str(extractor.extract.call_args[0][1]) == str( - FilePath.is_in(namespace_with_begin.filepath) + FilePath.is_in(namespace_with_begin.file_path) ) assert str(extractor.extract.call_args[0][2]) == str( - ProcessOwner.is_in(namespace_with_begin.processOwner) + ProcessOwner.is_in(namespace_with_begin.process_owner) ) - assert str(extractor.extract.call_args[0][3]) == str(TabURL.is_in(namespace_with_begin.tabURL)) + assert str(extractor.extract.call_args[0][3]) == str(TabURL.is_in(namespace_with_begin.tab_url)) def test_extract_when_given_include_non_exposure_and_exposure_types_causes_exit( diff --git a/tests/conftest.py b/tests/conftest.py index 66d5e95cc..c3494e4a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,15 +14,15 @@ def namespace(mocker): mock.begin = None mock.end = None mock.type = None - mock.c42username = None + mock.c42_username = None mock.actor = None mock.md5 = None mock.sha256 = None mock.source = None - mock.filename = None - mock.filepath = None - mock.processOwner = None - mock.tabURL = None + mock.file_name = None + mock.file_path = None + mock.process_owner = None + mock.tab_url = None mock.include_non_exposure = None mock.format = None mock.output_file = None diff --git a/tests/test_main.py b/tests/test_main.py index a8945af91..e50a9c2b8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,7 +3,7 @@ def test_securitydata_commands_load(capsys, mocker): - mocker.patch("sys.argv", ["code42", "securitydata", "print", "-h"]) + mocker.patch("sys.argv", ["code42", "security-data", "print", "-h"]) success = False try: main() From 07c846ffc48c7327f28439cac1f849a0ddfc55c4 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 14 Apr 2020 11:01:16 -0500 Subject: [PATCH 024/349] Feature/update profile (#29) --- CHANGELOG.md | 3 +- src/code42cli/cmds/profile.py | 47 +++-- src/code42cli/cmds/securitydata/enums.py | 1 + src/code42cli/commands.py | 2 +- src/code42cli/config.py | 40 +++- src/code42cli/profile.py | 24 ++- tests/cmds/test_profile.py | 79 +++++-- tests/test_config.py | 253 ++++++++++++----------- tests/test_profile.py | 21 +- 9 files changed, 303 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 154151b08..b1626bc3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased -### Changes +### Changed - `securitydata` renamed to `security-data`. - From `security-data` related subcommands (such as `print`): @@ -22,6 +22,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added - `code42 profile create` command. +- `code42 profile update` command. ### Removed diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 8d2723ff6..c98d5d5e9 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -48,10 +48,18 @@ def load_subcommands(): u"Create profile settings. The first profile created will be the default.", u"{} {}".format(usage_prefix, u"create "), handler=create_profile, - arg_customizer=_load_profile_create_descripions, + arg_customizer=_load_profile_create_descriptions, ) - return [show, list_all, use, reset_pw, create] + update = Command( + u"update", + u"Update an existing profile.", + u"{} {}".format(usage_prefix, u"update "), + handler=update_profile, + arg_customizer=_load_profile_update_descriptions, + ) + + return [show, list_all, use, reset_pw, create, update] def show_profile(profile=None): @@ -67,27 +75,31 @@ def show_profile(profile=None): def create_profile(profile, server, username, disable_ssl_errors=False): - """Sets the given profile using command line arguments.""" - if cliprofile.profile_exists(profile): - print_error(u"A profile named {} already exists.".format(profile)) - exit(1) - cliprofile.create_profile(profile, server, username, disable_ssl_errors) _prompt_for_allow_password_set(profile) +def update_profile(profile=None, server=None, username=None, disable_ssl_errors=None): + profile = cliprofile.get_profile(profile) + cliprofile.update_profile(profile.name, server, username, disable_ssl_errors) + _prompt_for_allow_password_set(profile.name) + + def prompt_for_password_reset(profile=None): """Securely prompts for your password and then stores it using keyring.""" c42profile = cliprofile.get_profile(profile) new_password = getpass() + _validate_connection(c42profile.authority_url, c42profile.username, new_password) + cliprofile.set_password(new_password, c42profile.name) - if not validate_connection(c42profile.authority_url, c42profile.username, new_password): + +def _validate_connection(authority, username, password): + if not validate_connection(authority, username, password): print_error( u"Your credentials failed to validate, so your password was not stored." u"Check your network connection and the spelling of your username and server URL." ) exit(1) - cliprofile.set_password(new_password, c42profile.name) def list_profiles(*args): @@ -110,13 +122,24 @@ def _load_profile_description(argument_collection): profile.set_help(PROFILE_HELP) -def _load_profile_create_descripions(argument_collection): +def _load_profile_create_descriptions(argument_collection): profile = argument_collection.arg_configs["profile"] + profile.set_help(u"The name to give the profile being created.") + _load_profile_settings_descriptions(argument_collection) + + +def _load_profile_update_descriptions(argument_collection): + profile = argument_collection.arg_configs["profile"] + profile.set_help(u"The name to give the profile being updated.") + _load_profile_settings_descriptions(argument_collection) + argument_collection.arg_configs["server"].add_short_option_name("-s") + argument_collection.arg_configs["username"].add_short_option_name("-u") + + +def _load_profile_settings_descriptions(argument_collection): server = argument_collection.arg_configs["server"] username = argument_collection.arg_configs["username"] - disable_ssl_errors = argument_collection.arg_configs["disable_ssl_errors"] - profile.set_help(u"The name to give the profile being created.") server.set_help(u"The url and port of the Code42 server.") username.set_help(u"The username of the Code42 API user.") disable_ssl_errors.set_help( diff --git a/src/code42cli/cmds/securitydata/enums.py b/src/code42cli/cmds/securitydata/enums.py index af89b06d9..ecd5082f4 100644 --- a/src/code42cli/cmds/securitydata/enums.py +++ b/src/code42cli/cmds/securitydata/enums.py @@ -46,6 +46,7 @@ def __iter__(self): class SearchArguments(object): """These string values should match `argparse` stored parameter names. For example, for the CLI argument `--c42-username`, the string should be `c42_username`.""" + ADVANCED_QUERY = u"advanced_query" BEGIN_DATE = u"begin" END_DATE = u"end" diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py index 04d27d7a3..39cac29c5 100644 --- a/src/code42cli/commands.py +++ b/src/code42cli/commands.py @@ -22,7 +22,7 @@ class Command(object): description (str or unicode): Descriptive text to be displayed when using -h. - usage (str, optional): A usage example to be displayed when using -h. + usage (str or unicode, optional): A usage example to be displayed when using -h. handler (function, optional): The function to be exectued when the command is run. arg_customizer (function, optional): A function accepting a single `ArgCollection` diff --git a/src/code42cli/config.py b/src/code42cli/config.py index 61cbb76a8..439ff1acd 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -7,6 +7,11 @@ from code42cli.compat import str +class NoConfigProfileError(Exception): + def __init__(self): + super(Exception, self).__init__(u"Profile does not exist.") + + class ConfigAccessor(object): DEFAULT_VALUE = u"__DEFAULT__" AUTHORITY_KEY = u"c42_authority_url" @@ -31,9 +36,9 @@ def get_profile(self, name=None): If the name does not exist or there is no existing profile, it will throw an exception. """ name = name or self._default_profile_name - if name not in self.parser.sections() or name == self.DEFAULT_VALUE: - raise Exception(u"Profile does not exist.") - return self.parser[name] + if name not in self._get_sections() or name == self.DEFAULT_VALUE: + raise NoConfigProfileError() + return self._get_profile(name) def get_all_profiles(self): """Returns all the available profiles.""" @@ -47,21 +52,30 @@ def create_profile(self, name, server, username, ignore_ssl_errors): """Creates a new profile if one does not already exist for that name.""" try: self.get_profile(name) - except Exception as ex: + except NoConfigProfileError as ex: if name is not None and name != self.DEFAULT_VALUE: self._create_profile_section(name) else: raise ex - profile = self.parser[name] - self._set_authority_url(server, profile) - self._set_username(username, profile) - self._set_ignore_ssl_errors(ignore_ssl_errors, profile) + + profile = self.get_profile(name) + self.update_profile(profile.name, server, username, ignore_ssl_errors) self._try_complete_setup(profile) + def update_profile(self, name, server=None, username=None, ignore_ssl_errors=None): + profile = self.get_profile(name) + if server: + self._set_authority_url(server, profile) + if username: + self._set_username(username, profile) + if ignore_ssl_errors is not None: + self._set_ignore_ssl_errors(ignore_ssl_errors, profile) + self._save() + def switch_default_profile(self, new_default_name): """Changes what is marked as the default profile in the internal section.""" if self.get_profile(new_default_name) is None: - raise Exception(u"Profile does not exist.") + raise NoConfigProfileError() self._internal[self.DEFAULT_PROFILE] = new_default_name self._save() print(u"{} has been set as the default profile.".format(new_default_name)) @@ -75,6 +89,12 @@ def _set_username(self, new_value, profile): def _set_ignore_ssl_errors(self, new_value, profile): profile[self.IGNORE_SSL_ERRORS_KEY] = str(new_value) + def _get_sections(self): + return self.parser.sections() + + def _get_profile(self, name): + return self.parser[name] + @property def _internal(self): return self.parser[self._INTERNAL_SECTION] @@ -84,7 +104,7 @@ def _default_profile_name(self): return self._internal[self.DEFAULT_PROFILE] def _get_profile_names(self): - names = list(self.parser.sections()) + names = list(self._get_sections()) names.remove(self._INTERNAL_SECTION) return names diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index b11fef994..9c4189684 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -1,5 +1,5 @@ import code42cli.password as password -from code42cli.config import ConfigAccessor, config_accessor +from code42cli.config import ConfigAccessor, config_accessor, NoConfigProfileError from code42cli.util import print_error, print_create_profile_help @@ -23,6 +23,11 @@ def username(self): def ignore_ssl_errors(self): return self._profile[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] + @property + def has_stored_password(self): + stored_password = password.get_stored_password(self) + return stored_password is not None and stored_password != u"" + def get_password(self): pwd = password.get_stored_password(self) if not pwd: @@ -37,13 +42,14 @@ def __str__(self): def _get_profile(profile_name=None): """Returns the profile for the given name.""" - return Code42Profile(config_accessor.get_profile(profile_name)) + config_profile = config_accessor.get_profile(profile_name) + return Code42Profile(config_profile) def get_profile(profile_name=None): try: return _get_profile(profile_name) - except Exception as ex: + except NoConfigProfileError as ex: print_error(str(ex)) print_create_profile_help() exit(1) @@ -53,7 +59,7 @@ def default_profile_exists(): try: profile = _get_profile() return profile.name and profile.name != ConfigAccessor.DEFAULT_VALUE - except Exception: + except NoConfigProfileError: return False @@ -61,7 +67,7 @@ def profile_exists(profile_name=None): try: _get_profile(profile_name) return True - except Exception: + except NoConfigProfileError: return False @@ -70,9 +76,17 @@ def switch_default_profile(profile_name): def create_profile(name, server, username, ignore_ssl_errors): + if profile_exists(name): + print_error(u"A profile named {} already exists.".format(name)) + exit(1) + config_accessor.create_profile(name, server, username, ignore_ssl_errors) +def update_profile(name, server, username, ignore_ssl_errors): + config_accessor.update_profile(name, server, username, ignore_ssl_errors) + + def get_all_profiles(): profiles = [Code42Profile(profile) for profile in config_accessor.get_all_profiles()] return profiles diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 24e080f5b..81c6eecd9 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -34,6 +34,18 @@ def mock_verify(mocker): return mocker.patch("code42cli.cmds.profile.validate_connection") +@pytest.fixture +def valid_connection(mock_verify): + mock_verify.return_value = True + return mock_verify + + +@pytest.fixture +def invalid_connection(mock_verify): + mock_verify.return_value = False + return mock_verify + + def test_show_profile_outputs_profile_info(capsys, mock_cliprofile_namespace, profile): profile.name = "testname" profile.authority_url = "example.com" @@ -58,18 +70,6 @@ def test_show_profile_when_password_set_outputs_password_note( assert "A password is set" not in capture.out -def test_create_profile_if_profile_exists_exits(capsys, mock_cliprofile_namespace): - mock_cliprofile_namespace.profile_exists.return_value = True - success = True - try: - profilecmd.create_profile("foo", "bar", "baz", True) - except SystemExit: - success = True - capture = capsys.readouterr() - assert "already exists" in capture.out - assert success - - def test_create_profile_if_user_sets_password_is_created( user_agreement, mock_verify, mock_cliprofile_namespace ): @@ -86,7 +86,7 @@ def test_create_profile_if_user_does_not_set_password_is_created( mock_cliprofile_namespace.create_profile.assert_called_once_with("foo", "bar", "baz", True) -def test_create_profile_if_user_does_not_set_password_does_not_save_password( +def test_create_profile_if_user_does_not_agree_does_not_save_password( user_disagreement, mock_verify, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False @@ -95,9 +95,8 @@ def test_create_profile_if_user_does_not_set_password_does_not_save_password( def test_create_profile_if_credentials_invalid_password_not_saved( - user_agreement, mock_verify, mock_cliprofile_namespace + user_agreement, invalid_connection, mock_cliprofile_namespace ): - mock_verify.return_value = False mock_cliprofile_namespace.profile_exists.return_value = False success = False try: @@ -109,14 +108,60 @@ def test_create_profile_if_credentials_invalid_password_not_saved( def test_create_profile_if_credentials_valid_password_saved( - mocker, user_agreement, mock_verify, mock_cliprofile_namespace + mocker, user_agreement, valid_connection, mock_cliprofile_namespace ): - mock_verify.return_value = True mock_cliprofile_namespace.profile_exists.return_value = False profilecmd.create_profile("foo", "bar", "baz", True) mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) +def test_update_profile_updates_existing_profile( + mock_cliprofile_namespace, user_agreement, valid_connection, profile +): + name = "foo" + profile.name = name + mock_cliprofile_namespace.get_profile.return_value = profile + + profilecmd.update_profile(name, "bar", "baz", True) + mock_cliprofile_namespace.update_profile.assert_called_once_with(name, "bar", "baz", True) + + +def test_update_profile_if_user_does_not_agree_does_not_save_password( + mock_cliprofile_namespace, user_disagreement, invalid_connection, profile +): + name = "foo" + profile.name = name + mock_cliprofile_namespace.get_profile.return_value = profile + assert not mock_cliprofile_namespace.set_password.call_count + + +def test_update_profile_if_credentials_invalid_password_not_saved( + user_agreement, invalid_connection, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + mock_cliprofile_namespace.get_profile.return_value = profile + + success = False + try: + profilecmd.create_profile("foo", "bar", "baz", True) + except SystemExit: + success = True + assert not mock_cliprofile_namespace.set_password.call_count + assert success + + +def test_update_profile_if_user_agrees_and_valid_connection_sets_password( + mocker, user_agreement, valid_connection, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + mock_cliprofile_namespace.get_profile.return_value = profile + + profilecmd.update_profile(name, "bar", "baz", True) + mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) + + def test_prompt_for_password_reset_if_credentials_valid_password_saved( mocker, user_agreement, mock_verify, mock_cliprofile_namespace ): diff --git a/tests/test_config.py b/tests/test_config.py index c78ba8d34..0cf0ce7c7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,15 +3,57 @@ import pytest from configparser import ConfigParser -from code42cli.config import ConfigAccessor +from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection +_TEST_PROFILE_NAME = "ProfileA" +_TEST_SECOND_PROFILE_NAME = "ProfileB" +_INTERNAL = "Internal" + + @pytest.fixture def mock_config_parser(mocker): return mocker.MagicMock(sepc=ConfigParser) +@pytest.fixture +def config_parser_for_multiple_profiles(mock_config_parser): + mock_config_parser.sections.return_value = [ + _INTERNAL, + _TEST_PROFILE_NAME, + _TEST_SECOND_PROFILE_NAME, + ] + mock_profile_a = create_mock_profile_object(_TEST_PROFILE_NAME, "test", "test") + mock_profile_b = create_mock_profile_object(_TEST_SECOND_PROFILE_NAME, "test", "test") + + mock_internal = create_internal_object(True, _TEST_PROFILE_NAME) + + def side_effect(item): + if item == _TEST_PROFILE_NAME: + return mock_profile_a + elif item == _TEST_SECOND_PROFILE_NAME: + return mock_profile_b + elif item == _INTERNAL: + return mock_internal + + mock_config_parser.__getitem__.side_effect = side_effect + return mock_config_parser + + +@pytest.fixture +def config_parser_for_create(mock_config_parser): + values = [[_INTERNAL], [_INTERNAL, _TEST_PROFILE_NAME]] + + def side_effect(): + if len(values) == 2: + return values.pop(0) + return values[0] + + mock_config_parser.sections.side_effect = side_effect + return mock_config_parser + + @pytest.fixture(autouse=True) def mock_saver(mocker): return mocker.patch("code42cli.util.open_file") @@ -30,7 +72,7 @@ def create_internal_object(is_complete, default_profile_name=None): ConfigAccessor.DEFAULT_PROFILE: default_profile_name, ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE: is_complete, } - internal_section = MockSection("Internal", internal_dict) + internal_section = MockSection(_INTERNAL, internal_dict) def getboolean(*args): return is_complete @@ -41,9 +83,9 @@ def getboolean(*args): def setup_parser_one_profile(profile, internal, parser): def side_effect(item): - if item == "ProfileA": + if item == _TEST_PROFILE_NAME: return profile - elif item == "Internal": + elif item == _INTERNAL: return internal parser.__getitem__.side_effect = side_effect @@ -51,169 +93,146 @@ def side_effect(item): class TestConfigAccessor(object): def test_get_profile_when_profile_does_not_exist_raises(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal"] + mock_config_parser.sections.return_value = [_INTERNAL] accessor = ConfigAccessor(mock_config_parser) - with pytest.raises(Exception): + with pytest.raises(NoConfigProfileError): accessor.get_profile("Profile Name that does not exist") def test_get_profile_when_profile_has_default_name_raises(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal"] + mock_config_parser.sections.return_value = [_INTERNAL] accessor = ConfigAccessor(mock_config_parser) - with pytest.raises(Exception): - accessor.get_profile("__DEFAULT__") + with pytest.raises(NoConfigProfileError): + accessor.get_profile(ConfigAccessor.DEFAULT_VALUE) def test_get_profile_returns_expected_profile(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal", "ProfileA"] + mock_config_parser.sections.return_value = [_INTERNAL, _TEST_PROFILE_NAME] accessor = ConfigAccessor(mock_config_parser) - accessor.get_profile("ProfileA") - assert mock_config_parser.__getitem__.call_args[0][0] == "ProfileA" + accessor.get_profile(_TEST_PROFILE_NAME) + assert mock_config_parser.__getitem__.call_args[0][0] == _TEST_PROFILE_NAME def test_get_all_profiles_excludes_internal_section(self, mock_config_parser): - mock_config_parser.sections.return_value = ["ProfileA", "Internal", "ProfileB"] + mock_config_parser.sections.return_value = [ + _TEST_PROFILE_NAME, + _INTERNAL, + _TEST_SECOND_PROFILE_NAME, + ] accessor = ConfigAccessor(mock_config_parser) profiles = accessor.get_all_profiles() for p in profiles: - if p.name == "Internal": + if p.name == _INTERNAL: assert False - def test_get_all_profiles_returns_profiles_with_expected_values(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile_a = create_mock_profile_object("ProfileA", "test", "test") - mock_profile_b = create_mock_profile_object("ProfileB", "test", "test") - - mock_internal = create_internal_object(True, "ProfileA") - - def side_effect(item): - if item == "ProfileA": - return mock_profile_a - elif item == "ProfileB": - return mock_profile_b - elif item == "Internal": - return mock_internal - - mock_config_parser.__getitem__.side_effect = side_effect + def test_get_all_profiles_returns_profiles_with_expected_values( + self, config_parser_for_multiple_profiles + ): + accessor = ConfigAccessor(config_parser_for_multiple_profiles) profiles = accessor.get_all_profiles() - assert profiles[0].name == "ProfileA" - assert profiles[1].name == "ProfileB" - - def test_switch_default_profile_switches_internal_value(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile_a = create_mock_profile_object("ProfileA", "test", "test") - mock_profile_b = create_mock_profile_object("ProfileB", "test", "test") - - mock_internal = create_internal_object(True, "ProfileA") - - def side_effect(item): - if item == "ProfileA": - return mock_profile_a - elif item == "ProfileB": - return mock_profile_b - elif item == "Internal": - return mock_internal + assert profiles[0].name == _TEST_PROFILE_NAME + assert profiles[1].name == _TEST_SECOND_PROFILE_NAME - mock_config_parser.__getitem__.side_effect = side_effect - accessor.switch_default_profile("ProfileB") - assert mock_internal[ConfigAccessor.DEFAULT_PROFILE] == "ProfileB" - - def test_switch_default_profile_saves(self, mock_config_parser, mock_saver): - mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile_a = create_mock_profile_object("ProfileA", "test", "test") - mock_profile_b = create_mock_profile_object("ProfileB", "test", "test") - - mock_internal = create_internal_object(True, "ProfileA") - - def side_effect(item): - if item == "ProfileA": - return mock_profile_a - elif item == "ProfileB": - return mock_profile_b - elif item == "Internal": - return mock_internal - - mock_config_parser.__getitem__.side_effect = side_effect - accessor.switch_default_profile("ProfileB") + def test_switch_default_profile_switches_internal_value( + self, config_parser_for_multiple_profiles + ): + accessor = ConfigAccessor(config_parser_for_multiple_profiles) + accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) + assert ( + config_parser_for_multiple_profiles[_INTERNAL][ConfigAccessor.DEFAULT_PROFILE] + == _TEST_SECOND_PROFILE_NAME + ) + + def test_switch_default_profile_saves(self, config_parser_for_multiple_profiles, mock_saver): + accessor = ConfigAccessor(config_parser_for_multiple_profiles) + accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) assert mock_saver.call_count def test_switch_default_profile_outputs_confirmation( - self, capsys, mock_config_parser, mock_saver + self, capsys, config_parser_for_multiple_profiles, mock_saver ): - mock_config_parser.sections.return_value = ["Internal", "ProfileA", "ProfileB"] - accessor = ConfigAccessor(mock_config_parser) - mock_profile_a = create_mock_profile_object("ProfileA", "test", "test") - mock_profile_b = create_mock_profile_object("ProfileB", "test", "test") - - mock_internal = create_internal_object(True, "ProfileA") - - def side_effect(item): - if item == "ProfileA": - return mock_profile_a - elif item == "ProfileB": - return mock_profile_b - elif item == "Internal": - return mock_internal - - mock_config_parser.__getitem__.side_effect = side_effect - accessor.switch_default_profile("ProfileB") + accessor = ConfigAccessor(config_parser_for_multiple_profiles) + accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) capture = capsys.readouterr() assert "set as the default profile" in capture.out - def test_create_profile_when_given_default_name_does_not_create(self, mock_config_parser): - mock_config_parser.sections.return_value = ["Internal", "ProfileA"] - accessor = ConfigAccessor(mock_config_parser) + def test_create_profile_when_given_default_name_does_not_create(self, config_parser_for_create): + accessor = ConfigAccessor(config_parser_for_create) with pytest.raises(Exception): accessor.create_profile(ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False) def test_create_profile_when_no_default_profile_sets_default( - self, mocker, mock_config_parser, mock_saver + self, mocker, config_parser_for_create, mock_saver ): - mock_config_parser.sections.return_value = ["Internal"] - mock_profile = create_mock_profile_object("ProfileA", None, None) + mock_profile = create_mock_profile_object(_TEST_PROFILE_NAME, None, None) mock_internal = create_internal_object(False) mock_internal["default_profile_is_complete"] = "False" - setup_parser_one_profile(mock_internal, mock_internal, mock_config_parser) - accessor = ConfigAccessor(mock_config_parser) + setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) + accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() - accessor.create_profile("ProfileA", "example.com", "bar", False) + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) assert accessor.switch_default_profile.call_count == 1 def test_create_profile_when_has_default_profile_does_not_set_default( - self, mocker, mock_config_parser, mock_saver + self, mocker, config_parser_for_create, mock_saver ): - mock_config_parser.sections.return_value = ["Internal"] - mock_profile = create_mock_profile_object("ProfileA", None, None) - mock_internal = create_internal_object(True, "ProfileA") - setup_parser_one_profile(mock_internal, mock_internal, mock_config_parser) - accessor = ConfigAccessor(mock_config_parser) + mock_profile = create_mock_profile_object(_TEST_PROFILE_NAME, None, None) + mock_internal = create_internal_object(True, _TEST_PROFILE_NAME) + setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) + accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() - accessor.create_profile("ProfileA", "example.com", "bar", False) + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) assert not accessor.switch_default_profile.call_count - def test_create_profile_when_not_existing_saves(self, mock_config_parser, mock_saver): - mock_config_parser.sections.return_value = ["Internal"] - mock_profile = create_mock_profile_object("ProfileA", None, None) + def test_create_profile_when_not_existing_saves(self, config_parser_for_create, mock_saver): + create_mock_profile_object(_TEST_PROFILE_NAME, None, None) mock_internal = create_internal_object(False) mock_internal["default_profile_is_complete"] = "False" - setup_parser_one_profile(mock_internal, mock_internal, mock_config_parser) - accessor = ConfigAccessor(mock_config_parser) + setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) + accessor = ConfigAccessor(config_parser_for_create) - accessor.create_profile("ProfileA", "example.com", "bar", False) + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) assert mock_saver.call_count def test_create_profile_when_not_existing_outputs_confirmation( - self, capsys, mock_config_parser, mock_saver + self, capsys, config_parser_for_create, mock_saver ): - mock_config_parser.sections.return_value = ["Internal"] - mock_profile = create_mock_profile_object("ProfileA", None, None) mock_internal = create_internal_object(False) mock_internal["default_profile_is_complete"] = "False" - setup_parser_one_profile(mock_internal, mock_internal, mock_config_parser) - accessor = ConfigAccessor(mock_config_parser) + setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) + accessor = ConfigAccessor(config_parser_for_create) - accessor.create_profile("ProfileA", "example.com", "bar", False) + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) capture = capsys.readouterr() assert "Successfully saved" in capture.out + + def test_update_profile_when_no_profile_exists_raises_exception( + self, config_parser_for_multiple_profiles + ): + accessor = ConfigAccessor(config_parser_for_multiple_profiles) + with pytest.raises(Exception): + accessor.update_profile("Non-existent Profile") + + def test_update_profile_updates_profile(self, config_parser_for_multiple_profiles): + accessor = ConfigAccessor(config_parser_for_multiple_profiles) + address = "NEW ADDRESS" + username = "NEW USERNAME" + + accessor.update_profile(_TEST_PROFILE_NAME, address, username, True) + assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.AUTHORITY_KEY] == address + assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.USERNAME_KEY] == username + assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] + + def test_update_profile_does_not_update_when_given_none( + self, config_parser_for_multiple_profiles + ): + accessor = ConfigAccessor(config_parser_for_multiple_profiles) + + # First, make sure they're not None + address = "NOT NONE" + username = "NOT NONE" + accessor.update_profile(_TEST_PROFILE_NAME, address, username, True) + + accessor.update_profile(_TEST_PROFILE_NAME, None, None, None) + assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.AUTHORITY_KEY] == address + assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.USERNAME_KEY] == username + assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] diff --git a/tests/test_profile.py b/tests/test_profile.py index d3553127a..681180ff8 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,7 +1,7 @@ import pytest import code42cli.profile as cliprofile -from code42cli.config import ConfigAccessor +from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection, create_mock_profile @@ -60,7 +60,7 @@ def test_get_profile_returns_expected_profile(config_accessor): def test_get_profile_when_config_accessor_throws_exits(config_accessor): - config_accessor.get_profile.side_effect = Exception() + config_accessor.get_profile.side_effect = NoConfigProfileError() with pytest.raises(SystemExit): profile = cliprofile.get_profile("testprofilename") @@ -72,7 +72,7 @@ def test_default_profile_exists_when_exists_returns_true(config_accessor): def test_default_profile_exists_when_not_exists_returns_false(config_accessor): - mock_section = MockSection("__DEFAULT__") + mock_section = MockSection(ConfigAccessor.DEFAULT_VALUE) config_accessor.get_profile.return_value = mock_section assert not cliprofile.default_profile_exists() @@ -84,7 +84,7 @@ def test_profile_exists_when_exists_returns_true(config_accessor): def test_profile_exists_when_not_exists_returns_false(config_accessor): - config_accessor.get_profile.side_effect = Exception() + config_accessor.get_profile.side_effect = NoConfigProfileError() assert not cliprofile.profile_exists("idontexist") @@ -94,6 +94,7 @@ def test_switch_default_profile_switches_to_expected_profile(config_accessor): def test_create_profile_uses_expected_profile_values(config_accessor): + config_accessor.get_profile.side_effect = NoConfigProfileError() profile_name = "profilename" server = "server" username = "username" @@ -104,6 +105,18 @@ def test_create_profile_uses_expected_profile_values(config_accessor): ) +def test_create_profile_if_profile_exists_exits(mocker, capsys, config_accessor): + config_accessor.get_profile.return_value = mocker.MagicMock() + success = True + try: + cliprofile.create_profile("foo", "bar", "baz", True) + except SystemExit: + success = True + capture = capsys.readouterr() + assert "already exists" in capture.out + assert success + + def test_get_all_profiles_returns_expected_profile_list(config_accessor): config_accessor.get_all_profiles.return_value = [ create_mock_profile("one"), From 4a60406ff44151dd029a42e572733331e0123ae2 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 14 Apr 2020 11:59:36 -0500 Subject: [PATCH 025/349] Default profile validation (#24) * -Add validation logic around default profile. -Add help message for when default is not set (or points to an invalid/missing entry). * use \n instead of empty print() calls. * add tests * update tests to throw NoConfigProfileError() --- src/code42cli/profile.py | 20 ++++++++++++++++++-- src/code42cli/util.py | 17 ++++++++++++++--- tests/test_profile.py | 21 ++++++++++++++++++++- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index 9c4189684..2c112d42c 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -1,7 +1,11 @@ import code42cli.password as password from code42cli.config import ConfigAccessor, config_accessor, NoConfigProfileError -from code42cli.util import print_error, print_create_profile_help - +from code42cli.util import ( + print_error, + print_create_profile_help, + print_set_default_profile_help, + print_no_existing_profile_message, +) class Code42Profile(object): def __init__(self, profile): @@ -47,6 +51,8 @@ def _get_profile(profile_name=None): def get_profile(profile_name=None): + if profile_name is None: + validate_default_profile() try: return _get_profile(profile_name) except NoConfigProfileError as ex: @@ -63,6 +69,16 @@ def default_profile_exists(): return False +def validate_default_profile(): + if not default_profile_exists(): + existing_profiles = get_all_profiles() + if not existing_profiles: + print_no_existing_profile_message() + else: + print_set_default_profile_help(existing_profiles) + exit(1) + + def profile_exists(profile_name=None): try: _get_profile(profile_name) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 42bf243f1..cbb60b358 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -59,9 +59,20 @@ def print_no_existing_profile_message(): def print_create_profile_help(): - print(u"") - print(u"To add a profile, use: ") - print_bold(u"\tcode42 profile create ") + print(u"\nTo add a profile, use: ") + print_bold(u"\tcode42 profile create \n") + + +def print_set_default_profile_help(existing_profiles): + print( + u"\nNo default profile set.\n", + u"\nUse the --profile flag to specify which profile to use.\n", + u"\nTo set the default profile (used whenever --profile argument is not provided), use:" + ) + print_bold(u"\tcode42 profile use ") + print(u"\nExisting profiles:") + for profile in existing_profiles: + print("\t{}".format(profile)) print(u"") diff --git a/tests/test_profile.py b/tests/test_profile.py index 681180ff8..7df50096e 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -58,7 +58,6 @@ def test_get_profile_returns_expected_profile(config_accessor): profile = cliprofile.get_profile("testprofilename") assert profile.name == "testprofilename" - def test_get_profile_when_config_accessor_throws_exits(config_accessor): config_accessor.get_profile.side_effect = NoConfigProfileError() with pytest.raises(SystemExit): @@ -77,6 +76,26 @@ def test_default_profile_exists_when_not_exists_returns_false(config_accessor): assert not cliprofile.default_profile_exists() +def test_validate_default_profile_prints_set_default_help_when_no_valid_default_but_another_profile_exists(capsys, config_accessor): + config_accessor.get_profile.side_effect = NoConfigProfileError() + config_accessor.get_all_profiles.return_value = [ + MockSection("thisprofilexists") + ] + with pytest.raises(SystemExit): + cliprofile.validate_default_profile() + capture = capsys.readouterr() + assert "No default profile set." in capture.out + + +def test_validate_default_profile_prints_create_profile_help_when_no_valid_default_and_no_other_profiles_exists(capsys, config_accessor): + config_accessor.get_profile.side_effect = NoConfigProfileError() + config_accessor.get_all_profiles.return_value = [] + with pytest.raises(SystemExit): + cliprofile.validate_default_profile() + capture = capsys.readouterr() + assert "No existing profile." in capture.out + + def test_profile_exists_when_exists_returns_true(config_accessor): mock_section = MockSection("testprofilename") config_accessor.get_profile.return_value = mock_section From 590a43d9045ab42fb25c66f381de0272d946fac2 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 14 Apr 2020 13:07:16 -0500 Subject: [PATCH 026/349] Bulk Skeletons (#25) --- .gitignore | 3 +- CHANGELOG.md | 9 ++- add_high_risk_employee.csv | 1 - src/code42cli/args.py | 4 +- src/code42cli/bulk.py | 61 +++++++++++++++++ src/code42cli/cmds/detectionlists/__init__.py | 0 src/code42cli/cmds/detectionlists/commands.py | 65 ++++++++++++++++++ src/code42cli/cmds/detectionlists/enums.py | 10 +++ .../cmds/detectionlists/high_risk.py | 67 +++++++++++++++++++ src/code42cli/cmds/detectionlists/main.py | 12 ++++ src/code42cli/cmds/profile.py | 16 ++--- .../cmds/securitydata/date_helper.py | 3 +- src/code42cli/cmds/securitydata/extraction.py | 10 +-- .../cmds/securitydata/logger_factory.py | 60 ++++------------- src/code42cli/commands.py | 18 +++-- src/code42cli/compat.py | 4 ++ src/code42cli/config.py | 1 + src/code42cli/logger.py | 39 +++++++++++ src/code42cli/main.py | 11 ++- src/code42cli/parser.py | 1 + src/code42cli/password.py | 3 +- src/code42cli/util.py | 1 - src/code42cli/worker.py | 52 ++++++++++++++ tests/cmds/detectionlists/__init__.py | 0 tests/cmds/detectionlists/test_high_risk.py | 32 +++++++++ tests/cmds/securitydata/conftest.py | 3 +- tests/cmds/securitydata/test_cursor_store.py | 2 +- tests/cmds/securitydata/test_date_helper.py | 2 +- tests/cmds/securitydata/test_extraction.py | 4 +- .../cmds/securitydata/test_logger_factory.py | 16 +---- tests/cmds/securitydata/test_main.py | 3 +- tests/cmds/test_profile.py | 2 +- tests/conftest.py | 10 ++- tests/test_bulk.py | 42 ++++++++++++ tests/test_commands.py | 4 +- tests/test_logger.py | 14 ++++ tests/test_password.py | 3 +- tests/test_sdk_client.py | 2 +- tests/test_worker.py | 19 ++++++ 39 files changed, 505 insertions(+), 104 deletions(-) delete mode 100644 add_high_risk_employee.csv create mode 100644 src/code42cli/bulk.py create mode 100644 src/code42cli/cmds/detectionlists/__init__.py create mode 100644 src/code42cli/cmds/detectionlists/commands.py create mode 100644 src/code42cli/cmds/detectionlists/enums.py create mode 100644 src/code42cli/cmds/detectionlists/high_risk.py create mode 100644 src/code42cli/cmds/detectionlists/main.py create mode 100644 src/code42cli/logger.py create mode 100644 src/code42cli/worker.py create mode 100644 tests/cmds/detectionlists/__init__.py create mode 100644 tests/cmds/detectionlists/test_high_risk.py create mode 100644 tests/test_bulk.py create mode 100644 tests/test_logger.py create mode 100644 tests/test_worker.py diff --git a/.gitignore b/.gitignore index 7b2c0a5bc..a0b076ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -# Test config file -*config.cfg +*.csv .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index b1626bc3c..5d4538c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,15 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added -- `code42 profile create` command. - `code42 profile update` command. +- `code42 profile create` command. +- `code42 detection-lists high-risk` commands: + - `bulk` with subcommands: + - `add`: that takes a csv file of users. + - `generate-template`: that creates the csv file template. And parameters: + - `cmd`: with the option `add`. + - `path` + - `add` that takes parameters: `--username`, `--cloud-aliases`, `--risk-factors`, and `--notes`. ### Removed diff --git a/add_high_risk_employee.csv b/add_high_risk_employee.csv deleted file mode 100644 index 4d01d3d5a..000000000 --- a/add_high_risk_employee.csv +++ /dev/null @@ -1 +0,0 @@ -user_id,risk_factors \ No newline at end of file diff --git a/src/code42cli/args.py b/src/code42cli/args.py index d038a593c..c8d7451c8 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -46,8 +46,8 @@ def extend(self, arg_config_dict): def get_auto_arg_configs(handler): - """Looks at the parameter names of `handler` and builds an `ArgConfigCollection` containing argparse - parameters based on them.""" + """Looks at the parameter names of `handler` and builds an `ArgConfigCollection` containing + `argparse` parameters based on them.""" arg_configs = ArgConfigCollection() if callable(handler): # get the number of positional and keyword args diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py new file mode 100644 index 000000000..2ed25864a --- /dev/null +++ b/src/code42cli/bulk.py @@ -0,0 +1,61 @@ +import os +import inspect +import csv + +from code42cli.compat import open, str +from code42cli.worker import Worker + + +def generate_template(handler, path=None): + """Looks at the parameter names of `handler` and creates a csv file with the same column names. + """ + if callable(handler): + argspec = inspect.getargspec(handler) + columns = [str(arg) for arg in argspec.args if arg not in [u"sdk", u"profile"]] + path = path or u"{0}/{1}.csv".format(os.getcwd(), str(handler.__name__)) + _write_template_file(path, columns) + + +def _write_template_file(path, columns): + with open(path, u"w", encoding=u"utf8") as new_csv: + new_csv.write(u",".join(columns)) + + +def create_bulk_processor(csv_file_path, row_handler): + """A factory method to create the bulk processor, useful for testing purposes.""" + return BulkProcessor(csv_file_path, row_handler) + + +class BulkProcessor(object): + """A class for bulk processing a csv file. + + Args: + csv_file_path (str or unicode): The path to the csv file for processing. + row_handler (callable): To be executed on each row given **kwargs representing the column + names mapped to the properties found in the row. For example, if the csv file header + looked like `prop_a,prop_b` and the next row looked like `1,test`, then row handler + would receive args `prop_a: '1', prop_b: 'test'` when processing row 1. + """ + + def __init__(self, csv_file_path, row_handler): + self.csv_file_path = csv_file_path + self._row_handler = row_handler + self.__worker = Worker(5) + + def run(self): + """Processes the csv file specified in the ctor, calling `self.row_handler` on each row.""" + rows = self._get_rows() + self._process_rows(rows) + self.__worker.wait() + + def _get_rows(self): + with open(self.csv_file_path, newline=u"", encoding=u"utf8") as csv_file: + return _create_dict_reader(csv_file) + + def _process_rows(self, rows): + for row in rows: + self.__worker.do_async(lambda **kwargs: self._row_handler(**kwargs), **row) + + +def _create_dict_reader(csv_file): + return csv.DictReader(csv_file) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py new file mode 100644 index 000000000..522b30841 --- /dev/null +++ b/src/code42cli/cmds/detectionlists/commands.py @@ -0,0 +1,65 @@ +from code42cli.cmds.detectionlists.enums import BulkCommandType +from code42cli.commands import Command + + +def create_usage_prefix(detection_list_name): + return u"code42 detection-list {}".format(detection_list_name) + + +def create_bulk_usage_prefix(detection_list_name): + return u"{} bulk".format(create_usage_prefix(detection_list_name)) + + +class DetectionListCommandFactory: + def __init__(self, detection_list_name): + self._name = detection_list_name + self._usage_prefix = create_usage_prefix(detection_list_name) + self._bulk_usage_prefix = create_bulk_usage_prefix(detection_list_name) + + def create_bulk_command(self, subcommand_loader): + return Command( + u"bulk", + u"Tools for executing bulk {} commands.".format(self._name), + subcommand_loader=subcommand_loader, + ) + + def create_add_command(self, handler, arg_customizer): + return Command( + u"add", + u"Add a user to the {} detection list.".format(self._name), + u"{} add ".format(self._usage_prefix), + handler=handler, + arg_customizer=arg_customizer, + ) + + def create_bulk_generate_template_command(self, handler): + return Command( + u"generate-template", + u"Generate the necessary csv template needed for bulk adding users.", + u"{} gen-template ".format(self._bulk_usage_prefix), + handler=handler, + arg_customizer=DetectionListCommandFactory._load_bulk_generate_template_description, + ) + + def create_bulk_add_command(self, handler): + return Command( + u"add", + u"Bulk add users to the {} detection list using a csv file.".format(self._name), + u"{} add ".format(self._bulk_usage_prefix), + handler=handler, + arg_customizer=self._load_bulk_add_description, + ) + + @staticmethod + def _load_bulk_generate_template_description(argument_collection): + cmd_type = argument_collection.arg_configs[u"cmd"] + cmd_type.set_help(u"The type of command the template with be used for.") + cmd_type.set_choices(BulkCommandType()) + + def _load_bulk_add_description(self, argument_collection): + csv_file = argument_collection.arg_configs[u"csv_file"] + csv_file.set_help( + u"The path to the csv file for bulk adding users to the {} detection list.".format( + self._name + ) + ) diff --git a/src/code42cli/cmds/detectionlists/enums.py b/src/code42cli/cmds/detectionlists/enums.py new file mode 100644 index 000000000..92a8f6c82 --- /dev/null +++ b/src/code42cli/cmds/detectionlists/enums.py @@ -0,0 +1,10 @@ +class DetectionLists(object): + DEPARTING_EMPLOYEE = u"departing-employee" + HIGH_RISK = u"high-risk" + + +class BulkCommandType(object): + ADD = u"add" + + def __iter__(self): + return iter([self.ADD]) diff --git a/src/code42cli/cmds/detectionlists/high_risk.py b/src/code42cli/cmds/detectionlists/high_risk.py new file mode 100644 index 000000000..d8c453861 --- /dev/null +++ b/src/code42cli/cmds/detectionlists/high_risk.py @@ -0,0 +1,67 @@ +from code42cli.cmds.detectionlists.enums import BulkCommandType, DetectionLists +from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory, create_usage_prefix +from code42cli.bulk import generate_template, create_bulk_processor + + +_NAME = DetectionLists.HIGH_RISK +_USAGE_PREFIX = create_usage_prefix(_NAME) + + +def load_subcommands(): + factory = DetectionListCommandFactory(_NAME) + bulk = factory.create_bulk_command(lambda: load_bulk_subcommands(factory)) + add = factory.create_add_command(add_high_risk_employee, _load_add_description) + return [bulk, add] + + +def load_bulk_subcommands(factory): + generate_template_cmd = factory.create_bulk_generate_template_command(generate_csv_file) + add = factory.create_bulk_add_command(bulk_add_high_risk_employees) + return [generate_template_cmd, add] + + +def generate_csv_file(cmd, path=None): + """Generates a csv template a user would need to fill-in for bulk adding users to the high + risk detection list.""" + handler = None + if cmd == BulkCommandType.ADD: + handler = add_high_risk_employee + generate_template(handler, path) + + +def bulk_add_high_risk_employees(sdk, profile, csv_file): + """Takes a csv file in the form `username,cloud_aliases,risk_factors,notes` with each row + representing an employee and adds each employee to the high risk detection list in a bulk + fashion. + + Args: + sdk (py42.sdk.SDKClient): The py42 sdk. + profile (Code42Profile): The profile under which to execute this command. + csv_file (str): The path to the csv file containing rows of users. + """ + processor = create_bulk_processor( + csv_file, lambda **kwargs: add_high_risk_employee(sdk, profile, **kwargs) + ) + processor.run() + + +def add_high_risk_employee( + sdk, profile, username, cloud_aliases=None, risk_factors=None, notes=None +): + """Adds the user with the given username to the high risk detection list. + + Args: + sdk (py42.sdk.SDKClient): The py42 sdk. + profile (Code42Profile): The profile under which to execute this command. + username (str): The username for the user. + cloud_aliases (iter[str]): A list of cloud aliases associated with the user. + risk_factors (iter[str]): The list of risk factors associated with the user. + notes (str): Notes about the user. + """ + + +def _load_add_description(argument_collection): + username = argument_collection.arg_configs[u"username"] + risk_factors = argument_collection.arg_configs[u"risk_factors"] + username.set_help(u"A user profile ID for detection lists.") + risk_factors.set_help(u"Risk factors associated with the employee.") diff --git a/src/code42cli/cmds/detectionlists/main.py b/src/code42cli/cmds/detectionlists/main.py new file mode 100644 index 000000000..627ef9018 --- /dev/null +++ b/src/code42cli/cmds/detectionlists/main.py @@ -0,0 +1,12 @@ +import code42cli.cmds.detectionlists.high_risk as high_risk +from code42cli.commands import Command + + +def load_subcommands(): + return [ + Command( + u"high-risk", + u"Add or remove users from the `high risk` detection list.", + subcommand_loader=high_risk.load_subcommands, + ) + ] diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index c98d5d5e9..c960a2c8d 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -118,28 +118,28 @@ def use_profile(profile): def _load_profile_description(argument_collection): - profile = argument_collection.arg_configs["profile"] + profile = argument_collection.arg_configs[u"profile"] profile.set_help(PROFILE_HELP) def _load_profile_create_descriptions(argument_collection): - profile = argument_collection.arg_configs["profile"] + profile = argument_collection.arg_configs[u"profile"] profile.set_help(u"The name to give the profile being created.") _load_profile_settings_descriptions(argument_collection) def _load_profile_update_descriptions(argument_collection): - profile = argument_collection.arg_configs["profile"] + profile = argument_collection.arg_configs[u"profile"] profile.set_help(u"The name to give the profile being updated.") _load_profile_settings_descriptions(argument_collection) - argument_collection.arg_configs["server"].add_short_option_name("-s") - argument_collection.arg_configs["username"].add_short_option_name("-u") + argument_collection.arg_configs[u"server"].add_short_option_name(u"-s") + argument_collection.arg_configs[u"username"].add_short_option_name(u"-u") def _load_profile_settings_descriptions(argument_collection): - server = argument_collection.arg_configs["server"] - username = argument_collection.arg_configs["username"] - disable_ssl_errors = argument_collection.arg_configs["disable_ssl_errors"] + server = argument_collection.arg_configs[u"server"] + username = argument_collection.arg_configs[u"username"] + disable_ssl_errors = argument_collection.arg_configs[u"disable_ssl_errors"] server.set_help(u"The url and port of the Code42 server.") username.set_help(u"The username of the Code42 API user.") disable_ssl_errors.set_help( diff --git a/src/code42cli/cmds/securitydata/date_helper.py b/src/code42cli/cmds/securitydata/date_helper.py index 6a9eb8c6c..c3dbfb6bb 100644 --- a/src/code42cli/cmds/securitydata/date_helper.py +++ b/src/code42cli/cmds/securitydata/date_helper.py @@ -1,5 +1,6 @@ -from c42eventextractor.common import convert_datetime_to_timestamp from datetime import datetime, timedelta + +from c42eventextractor.common import convert_datetime_to_timestamp from py42.sdk.queries.fileevents.filters.event_filter import EventTimestamp _MAX_LOOK_BACK_DAYS = 90 diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index c0393bb18..dd4c78435 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -1,6 +1,7 @@ from __future__ import print_function import json + from c42eventextractor import FileEventHandlers from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.filters import * @@ -11,7 +12,7 @@ IS_INCREMENTAL_KEY, SearchArguments, ) -from code42cli.cmds.securitydata.logger_factory import get_error_logger +from code42cli.logger import get_error_logger from code42cli.cmds.shared.cursor_store import FileEventCursorStore from code42cli.compat import str from code42cli.util import is_interactive, print_bold, print_error, print_to_stderr @@ -24,12 +25,13 @@ def extract(sdk, profile, output_logger, args): """Extracts file events using the given command-line arguments. Args: - output_logger: The logger specified by which subcommand you use. For example, + sdk (py42.sdk.SDKClient): The py42 sdk. + profile (Code42Profile): The profile under which to execute this command. + output_logger (Logger): The logger specified by which subcommand you use. For example, print: uses a logger that streams to stdout. write-to: uses a logger that logs to a file. send-to: uses a logger that sends logs to a server. - args: - Command line args used to build up file event query filters. + args: Command line args used to build up file event query filters. """ store = _create_cursor_store(args, profile) filters = _get_filters(args, store) diff --git a/src/code42cli/cmds/securitydata/logger_factory.py b/src/code42cli/cmds/securitydata/logger_factory.py index 3dc2b253b..a5a0b8947 100644 --- a/src/code42cli/cmds/securitydata/logger_factory.py +++ b/src/code42cli/cmds/securitydata/logger_factory.py @@ -1,20 +1,16 @@ +import logging import sys -import logging from c42eventextractor.logging.formatters import ( FileEventDictToCEFFormatter, FileEventDictToJSONFormatter, FileEventDictToRawJSONFormatter, ) from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper -from logging.handlers import RotatingFileHandler -from threading import Lock from code42cli.cmds.securitydata.enums import OutputFormat -from code42cli.compat import str -from code42cli.util import get_url_parts, get_user_project_path, print_error - -_logger_deps_lock = Lock() +from code42cli.util import get_url_parts, print_error +from code42cli.logger import logger_has_handlers, logger_deps_lock, apply_logger_dependencies def get_logger_for_stdout(output_format): @@ -24,11 +20,11 @@ def get_logger_for_stdout(output_format): output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. """ logger = logging.getLogger(u"code42_stdout_{0}".format(output_format.lower())) - if _logger_has_handlers(logger): + if logger_has_handlers(logger): return logger - with _logger_deps_lock: - if not _logger_has_handlers(logger): + with logger_deps_lock: + if not logger_has_handlers(logger): handler = logging.StreamHandler(sys.stdout) return _init_logger(logger, handler, output_format) return logger @@ -42,11 +38,11 @@ def get_logger_for_file(filename, output_format): output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. """ logger = logging.getLogger(u"code42_file_{0}".format(output_format.lower())) - if _logger_has_handlers(logger): + if logger_has_handlers(logger): return logger - with _logger_deps_lock: - if not _logger_has_handlers(logger): + with logger_deps_lock: + if not logger_has_handlers(logger): handler = logging.FileHandler(filename, delay=True, encoding="utf-8") return _init_logger(logger, handler, output_format) return logger @@ -61,11 +57,11 @@ def get_logger_for_server(hostname, protocol, output_format): output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. """ logger = logging.getLogger(u"code42_syslog_{0}".format(output_format.lower())) - if _logger_has_handlers(logger): + if logger_has_handlers(logger): return logger - with _logger_deps_lock: - if not _logger_has_handlers(logger): + with logger_deps_lock: + if not logger_has_handlers(logger): url_parts = get_url_parts(hostname) port = url_parts[1] or 514 try: @@ -79,40 +75,10 @@ def get_logger_for_server(hostname, protocol, output_format): return logger -def get_error_logger(): - """Gets the logger where exceptions are logged.""" - log_path = get_user_project_path(u"log") - log_path = u"{0}/code42_errors.log".format(log_path) - logger = logging.getLogger(u"code42_error_logger") - if _logger_has_handlers(logger): - return logger - - with _logger_deps_lock: - if not _logger_has_handlers(logger): - formatter = logging.Formatter(u"%(asctime)s %(message)s") - handler = RotatingFileHandler(log_path, maxBytes=250000000, encoding="utf-8") - return _apply_logger_dependencies(logger, handler, formatter) - return logger - - -def _logger_has_handlers(logger): - return len(logger.handlers) - - def _init_logger(logger, handler, output_format): formatter = _get_formatter(output_format) logger.setLevel(logging.INFO) - return _apply_logger_dependencies(logger, handler, formatter) - - -def _apply_logger_dependencies(logger, handler, formatter): - try: - handler.setFormatter(formatter) - logger.addHandler(handler) - except Exception as ex: - print_error(str(ex)) - exit(1) - return logger + return apply_logger_dependencies(logger, handler, formatter) def _get_formatter(output_format): diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py index 39cac29c5..e8320add5 100644 --- a/src/code42cli/commands.py +++ b/src/code42cli/commands.py @@ -11,7 +11,7 @@ def __init__(self, _dict): class Command(object): - """Represents a function that a CLI user can execute. Add a command to + """Represents a function that a CLI user can execute. Add a command to `code42cli.main._load_top_commands` or as a subcommand of one those commands to make it available for use. @@ -101,15 +101,25 @@ def get_arg_configs(self): def _get_arg_kvps(parsed_args, handler): - # transform parsed args from argparse into a dict + # transform parsed args from `argparse` into a dict kvps = dict(vars(parsed_args)) kvps.pop(u"func", None) return _inject_params(kvps, handler) def _inject_params(kvps, handler): - """automatically populates parameters named "sdk" or "profile" with instances of the sdk - and profile, respectively.""" + """Automatically populates parameters named "sdk" or "profile" with instances of the sdk and + profile, respectively. + + Args: + kvps (dict): A dictionary of the parsed command line arguments. + handler (callable): The function or command responsible for processing the command line + arguments. + + Returns: + dict: The dictionary of parsed command line arguments with possibly additional populated + fields. + """ if _handler_has_arg(u"sdk", handler): profile_name = kvps.pop(u"profile", None) debug = kvps.pop(u"debug", None) diff --git a/src/code42cli/compat.py b/src/code42cli/compat.py index 620df9e97..2d21273f7 100644 --- a/src/code42cli/compat.py +++ b/src/code42cli/compat.py @@ -21,6 +21,8 @@ open = io.open import repr as reprlib + + import Queue as queue else: from urllib.parse import urljoin, urlparse @@ -28,3 +30,5 @@ open = open import reprlib + + import queue diff --git a/src/code42cli/config.py b/src/code42cli/config.py index 439ff1acd..dc0159c29 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -1,6 +1,7 @@ from __future__ import print_function import os + from configparser import ConfigParser import code42cli.util as util diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py new file mode 100644 index 000000000..940c110f9 --- /dev/null +++ b/src/code42cli/logger.py @@ -0,0 +1,39 @@ +import logging +from logging.handlers import RotatingFileHandler +from threading import Lock + +from code42cli.compat import str +from code42cli.util import get_user_project_path, print_error + + +logger_deps_lock = Lock() + + +def get_error_logger(): + """Gets the logger where exceptions are logged.""" + log_path = get_user_project_path(u"log") + log_path = u"{0}/code42_errors.log".format(log_path) + logger = logging.getLogger(u"code42_error_logger") + if logger_has_handlers(logger): + return logger + + with logger_deps_lock: + if not logger_has_handlers(logger): + formatter = logging.Formatter("%(asctime)s %(message)s") + handler = RotatingFileHandler(log_path, maxBytes=250000000, encoding="utf-8") + return apply_logger_dependencies(logger, handler, formatter) + return logger + + +def logger_has_handlers(logger): + return len(logger.handlers) + + +def apply_logger_dependencies(logger, handler, formatter): + try: + handler.setFormatter(formatter) + logger.addHandler(handler) + except Exception as ex: + print_error(str(ex)) + exit(1) + return logger diff --git a/src/code42cli/main.py b/src/code42cli/main.py index ba591c9e6..0a0f44ae6 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,8 +1,8 @@ -import sys - import platform +import sys from code42cli.cmds import profile +from code42cli.cmds.detectionlists import main as dlmain from code42cli.cmds.securitydata import main as secmain from code42cli.commands import Command from code42cli.invoker import CommandInvoker @@ -20,7 +20,7 @@ def main(): - top = Command("", "", subcommand_loader=_load_top_commands) + top = Command(u"", u"", subcommand_loader=_load_top_commands) invoker = CommandInvoker(top) invoker.run(sys.argv[1:]) @@ -35,6 +35,11 @@ def _load_top_commands(): u"Tools for getting security related data, such as file events.", subcommand_loader=secmain.load_subcommands, ), + Command( + u"detection-lists", + u"For adding and removing employees from detection lists.", + subcommand_loader=dlmain.load_subcommands, + ), ] diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py index 6ef7cc1d3..9c2b6f8cc 100644 --- a/src/code42cli/parser.py +++ b/src/code42cli/parser.py @@ -1,5 +1,6 @@ import argparse from argparse import RawDescriptionHelpFormatter, SUPPRESS + from py42.__version__ import __version__ as py42version from code42cli.__version__ import __version__ as cliversion diff --git a/src/code42cli/password.py b/src/code42cli/password.py index 7c3e43996..9270342b1 100644 --- a/src/code42cli/password.py +++ b/src/code42cli/password.py @@ -1,8 +1,9 @@ from __future__ import print_function -import keyring from getpass import getpass +import keyring + from code42cli.util import does_user_agree _ROOT_SERVICE_NAME = u"code42cli" diff --git a/src/code42cli/util.py b/src/code42cli/util.py index cbb60b358..6f8d439b3 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,7 +1,6 @@ from __future__ import print_function, with_statement import sys - from os import makedirs, path from code42cli.compat import open diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py new file mode 100644 index 000000000..8d420052d --- /dev/null +++ b/src/code42cli/worker.py @@ -0,0 +1,52 @@ +from threading import Thread, Lock + +from code42cli.compat import queue +from code42cli.logger import get_error_logger + + +class Worker(object): + def __init__(self, thread_count): + self._queue = queue.Queue() + self._thread_count = thread_count + self._error_logger = get_error_logger() + self.__started = False + self.__start_lock = Lock() + + def do_async(self, func, *args, **kwargs): + """Execute the given func asynchronously given *args and **kwargs. + + Args: + func (callable): The function to execute asynchronously. + *args (iter): Positional args to pass to the function. + **kwargs (dict): Key-value args to pass to the function. + """ + if not self.__started: + with self.__start_lock: + if not self.__started: + self.__start() + self.__started = True + self._queue.put({u"func": func, u"args": args, u"kwargs": kwargs}) + + def wait(self): + """Wait for the tasks in the queue to complete. This should usually be called before + program termination.""" + self._queue.join() + + def _process_queue(self): + while True: + try: + task = self._queue.get() + func = task[u"func"] + args = task[u"args"] + kwargs = task[u"kwargs"] + func(*args, **kwargs) + except Exception as ex: + self._error_logger.error(ex) + finally: + self._queue.task_done() + + def __start(self): + for _ in range(0, self._thread_count): + t = Thread(target=self._process_queue) + t.daemon = True + t.start() diff --git a/tests/cmds/detectionlists/__init__.py b/tests/cmds/detectionlists/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cmds/detectionlists/test_high_risk.py b/tests/cmds/detectionlists/test_high_risk.py new file mode 100644 index 000000000..c93879698 --- /dev/null +++ b/tests/cmds/detectionlists/test_high_risk.py @@ -0,0 +1,32 @@ +import pytest + +from code42cli.cmds.detectionlists.high_risk import ( + generate_csv_file, + add_high_risk_employee, + bulk_add_high_risk_employees, +) + + +@pytest.fixture +def bulk_template_generator(mocker): + return mocker.patch("code42cli.cmds.detectionlists.high_risk.generate_template") + + +def test_generate_csv_file_generates_template(bulk_template_generator): + path = "some/path" + generate_csv_file("add", path) + bulk_template_generator.assert_called_once_with(add_high_risk_employee, path) + + +def test_bulk_add_high_risk_employees_runs(mocker, bulk_processor, sdk, profile): + factory = mocker.patch("code42cli.cmds.detectionlists.high_risk.create_bulk_processor") + factory.return_value = bulk_processor + bulk_add_high_risk_employees(sdk, profile, "") + assert bulk_processor.run.call_count == 1 + + +def test_bulk_add_high_risk_employees_creates_processor(mocker, bulk_processor, sdk, profile): + factory = mocker.patch("code42cli.cmds.detectionlists.high_risk.create_bulk_processor") + factory.return_value = bulk_processor + bulk_add_high_risk_employees(sdk, profile, "csv_test") + assert factory.call_args[0][0] == "csv_test" diff --git a/tests/cmds/securitydata/conftest.py b/tests/cmds/securitydata/conftest.py index 307eba866..13a28997e 100644 --- a/tests/cmds/securitydata/conftest.py +++ b/tests/cmds/securitydata/conftest.py @@ -1,7 +1,8 @@ import json as json_module -import pytest from datetime import datetime, timedelta +import pytest + SECURITYDATA_NAMESPACE = "code42cli.cmds.securitydata" diff --git a/tests/cmds/securitydata/test_cursor_store.py b/tests/cmds/securitydata/test_cursor_store.py index 5ceddbaeb..4a391b605 100644 --- a/tests/cmds/securitydata/test_cursor_store.py +++ b/tests/cmds/securitydata/test_cursor_store.py @@ -1,6 +1,6 @@ -from c42eventextractor.extractors import INSERTION_TIMESTAMP_FIELD_NAME from os import path +from c42eventextractor.extractors import INSERTION_TIMESTAMP_FIELD_NAME from code42cli.cmds.shared.cursor_store import BaseCursorStore, FileEventCursorStore diff --git a/tests/cmds/securitydata/test_date_helper.py b/tests/cmds/securitydata/test_date_helper.py index 00f368bb2..dd78ae235 100644 --- a/tests/cmds/securitydata/test_date_helper.py +++ b/tests/cmds/securitydata/test_date_helper.py @@ -1,6 +1,6 @@ import pytest - from code42cli.cmds.securitydata.date_helper import create_event_timestamp_filter + from .conftest import ( begin_date_list, begin_date_list_with_time, diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index 3dd34bd18..901c035d6 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -1,9 +1,9 @@ +import code42cli.cmds.securitydata.extraction as extraction_module import pytest +from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions from py42.sdk import SDKClient from py42.sdk.queries.fileevents.filters import * -import code42cli.cmds.securitydata.extraction as extraction_module -from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions from .conftest import ( SECURITYDATA_NAMESPACE, begin_date_str, diff --git a/tests/cmds/securitydata/test_logger_factory.py b/tests/cmds/securitydata/test_logger_factory.py index 6f9ebdea5..aedb9eeb7 100644 --- a/tests/cmds/securitydata/test_logger_factory.py +++ b/tests/cmds/securitydata/test_logger_factory.py @@ -1,13 +1,12 @@ import logging + +import code42cli.cmds.securitydata.logger_factory as factory import pytest from c42eventextractor.logging.formatters import ( FileEventDictToCEFFormatter, FileEventDictToJSONFormatter, FileEventDictToRawJSONFormatter, ) -from logging.handlers import RotatingFileHandler - -import code42cli.cmds.securitydata.logger_factory as factory @pytest.fixture @@ -151,14 +150,3 @@ def test_get_logger_for_server_when_hostname_includes_port_constructs_handler_wi no_priority_syslog_handler_wrapper.assert_called_once_with( "example.com", port=999, protocol="TCP" ) - - -def test_get_error_logger_when_called_twice_only_sets_handler_once(): - _ = factory.get_error_logger() - logger = factory.get_error_logger() - assert len(logger.handlers) == 1 - - -def test_get_error_logger_uses_rotating_file_handler(): - logger = factory.get_error_logger() - assert type(logger.handlers[0]) == RotatingFileHandler diff --git a/tests/cmds/securitydata/test_main.py b/tests/cmds/securitydata/test_main.py index 813e0f65c..cf7616389 100644 --- a/tests/cmds/securitydata/test_main.py +++ b/tests/cmds/securitydata/test_main.py @@ -1,6 +1,5 @@ -import pytest - import code42cli.cmds.securitydata.main as main +import pytest @pytest.fixture diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 81c6eecd9..5701db86c 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,6 +1,6 @@ +import code42cli.cmds.profile as profilecmd import pytest -import code42cli.cmds.profile as profilecmd from ..conftest import create_mock_profile diff --git a/tests/conftest.py b/tests/conftest.py index c3494e4a1..9d9f28a87 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,10 @@ -import pytest from argparse import Namespace -from py42.sdk import SDKClient +import pytest +from code42cli.bulk import BulkProcessor from code42cli.config import ConfigAccessor from code42cli.profile import Code42Profile +from py42.sdk import SDKClient @pytest.fixture @@ -99,3 +100,8 @@ def func_with_sdk(sdk, one, two, three=None, four=None): def func_with_args(args): pass + + +@pytest.fixture +def bulk_processor(mocker): + return mocker.MagicMock(spec=BulkProcessor) diff --git a/tests/test_bulk.py b/tests/test_bulk.py new file mode 100644 index 000000000..76b76eff9 --- /dev/null +++ b/tests/test_bulk.py @@ -0,0 +1,42 @@ +from io import IOBase + +from code42cli.bulk import generate_template, BulkProcessor + + +def test_generate_template_uses_expected_path_and_column_names(mocker): + def func_for_bulk(sdk, profile, test1, test2): + pass + + file_path = "some/path" + mock_open = mocker.patch("code42cli.bulk.open") + mock_open.return_value = mocker.MagicMock(spec=IOBase) + template_file = mock_open.return_value.__enter__.return_value + + generate_template(func_for_bulk, file_path) + mock_open.assert_called_once_with(file_path, u"w", encoding=u"utf8") + template_file.write.assert_called_once_with("test1,test2") + + +def test_generate_template_when_given_non_callable_handler_does_not_create(mocker): + mock_open = mocker.patch("code42cli.bulk.open") + generate_template(None, "some/path") + assert not mock_open.call_count + + +class TestBulkProcessor(object): + def test_run_processes_rows(self, mocker): + processed_rows = [] + + def func_for_bulk(test1, test2): + processed_rows.append((test1, test2)) + + mocker.patch("code42cli.bulk.open") + dict_reader = mocker.patch("code42cli.bulk._create_dict_reader") + dict_reader.return_value = [ + {"test1": 1, "test2": 2}, + {"test1": 3, "test2": 4}, + {"test1": 5, "test2": 6}, + ] + processor = BulkProcessor("some/path", func_for_bulk) + processor.run() + assert processed_rows == [(1, 2), (3, 4), (5, 6)] diff --git a/tests/test_commands.py b/tests/test_commands.py index 3824eb4d7..6adf51425 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,9 +1,9 @@ import pytest -from py42.sdk import SDKClient - from code42cli.args import ArgConfig from code42cli.commands import Command, DictObject from code42cli.profile import Code42Profile +from py42.sdk import SDKClient + from .conftest import ( func_keyword_args, func_mixed_args, diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 000000000..ae82da971 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,14 @@ +from logging.handlers import RotatingFileHandler + +import code42cli.logger as factory + + +def test_get_error_logger_when_called_twice_only_sets_handler_once(): + _ = factory.get_error_logger() + logger = factory.get_error_logger() + assert len(logger.handlers) == 1 + + +def test_get_error_logger_uses_rotating_file_handler(): + logger = factory.get_error_logger() + assert type(logger.handlers[0]) == RotatingFileHandler diff --git a/tests/test_password.py b/tests/test_password.py index 07c96bdf7..931b544f2 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,6 +1,5 @@ -import pytest - import code42cli.password as password +import pytest _USERNAME = "test.username" diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index a4330ca6d..7209aeaba 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,8 +1,8 @@ import py42.sdk import py42.sdk.settings.debug as debug import pytest - from code42cli.sdk_client import create_sdk, validate_connection + from .conftest import create_mock_profile diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 000000000..631bcec3e --- /dev/null +++ b/tests/test_worker.py @@ -0,0 +1,19 @@ +import time + +from code42cli.worker import Worker + + +class TestWorker(object): + def test_is_async(self): + worker = Worker(5) + demo_ls = [] + + def async_func(): + # Wait so that the line under `do_async` happens first, proving that it's async + time.sleep(0.01) + demo_ls.append(2) + + worker.do_async(async_func) + demo_ls.append(1) + worker.wait() + assert demo_ls == [1, 2] From d27f6db4c3093385aefe85ee7873a638b7692c61 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 14 Apr 2020 14:50:05 -0500 Subject: [PATCH 027/349] Chore/profile name in profile cmds (#31) --- src/code42cli/args.py | 2 +- src/code42cli/cmds/profile.py | 28 ++++++++++++++-------------- src/code42cli/profile.py | 1 + src/code42cli/util.py | 2 +- tests/cmds/test_profile.py | 2 +- tests/test_profile.py | 13 ++++++++----- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/code42cli/args.py b/src/code42cli/args.py index c8d7451c8..90266d29a 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -75,7 +75,7 @@ def _create_auto_args_config(arg_position, key, argspec, num_args, num_kw_args): param_name = key.replace(u"_", u"-") difference = num_args - num_kw_args last_positional_arg_idx = difference - 1 - # postional arguments will come first, so if the arg position + # positional arguments will come first, so if the arg position # is greater than the index of the last positional arg, it's a kwarg. if arg_position > last_positional_arg_idx: # this is a keyword arg, treat it as an optional cli arg. diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index c960a2c8d..997636f14 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -3,7 +3,7 @@ from getpass import getpass import code42cli.profile as cliprofile -from code42cli.args import PROFILE_HELP +from code42cli.args import PROFILE_HELP, ArgConfig from code42cli.commands import Command from code42cli.sdk_client import validate_connection from code42cli.util import does_user_agree, print_error, print_no_existing_profile_message @@ -18,7 +18,7 @@ def load_subcommands(): u"Print the details of a profile.", u"{} {}".format(usage_prefix, u"show "), handler=show_profile, - arg_customizer=_load_profile_description, + arg_customizer=_load_optional_profile_description, ) list_all = Command( @@ -40,7 +40,7 @@ def load_subcommands(): u"Change the stored password for a profile.", u"{} {}".format(usage_prefix, u"reset-pw "), handler=prompt_for_password_reset, - arg_customizer=_load_profile_description, + arg_customizer=_load_optional_profile_description, ) create = Command( @@ -62,9 +62,9 @@ def load_subcommands(): return [show, list_all, use, reset_pw, create, update] -def show_profile(profile=None): +def show_profile(name=None): """Prints the given profile to stdout.""" - c42profile = cliprofile.get_profile(profile) + c42profile = cliprofile.get_profile(name) print(u"\n{0}:".format(c42profile.name)) print(u"\t* username = {}".format(c42profile.username)) print(u"\t* authority url = {}".format(c42profile.authority_url)) @@ -79,15 +79,15 @@ def create_profile(profile, server, username, disable_ssl_errors=False): _prompt_for_allow_password_set(profile) -def update_profile(profile=None, server=None, username=None, disable_ssl_errors=None): - profile = cliprofile.get_profile(profile) +def update_profile(name=None, server=None, username=None, disable_ssl_errors=None): + profile = cliprofile.get_profile(name) cliprofile.update_profile(profile.name, server, username, disable_ssl_errors) _prompt_for_allow_password_set(profile.name) -def prompt_for_password_reset(profile=None): +def prompt_for_password_reset(name=None): """Securely prompts for your password and then stores it using keyring.""" - c42profile = cliprofile.get_profile(profile) + c42profile = cliprofile.get_profile(name) new_password = getpass() _validate_connection(c42profile.authority_url, c42profile.username, new_password) cliprofile.set_password(new_password, c42profile.name) @@ -117,20 +117,20 @@ def use_profile(profile): cliprofile.switch_default_profile(profile) -def _load_profile_description(argument_collection): - profile = argument_collection.arg_configs[u"profile"] +def _load_optional_profile_description(argument_collection): + profile = argument_collection.arg_configs[u"name"] + profile.add_short_option_name(u"-n") profile.set_help(PROFILE_HELP) def _load_profile_create_descriptions(argument_collection): profile = argument_collection.arg_configs[u"profile"] - profile.set_help(u"The name to give the profile being created.") + profile.set_help(PROFILE_HELP) _load_profile_settings_descriptions(argument_collection) def _load_profile_update_descriptions(argument_collection): - profile = argument_collection.arg_configs[u"profile"] - profile.set_help(u"The name to give the profile being updated.") + _load_optional_profile_description(argument_collection) _load_profile_settings_descriptions(argument_collection) argument_collection.arg_configs[u"server"].add_short_option_name(u"-s") argument_collection.arg_configs[u"username"].add_short_option_name(u"-u") diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index 2c112d42c..a7cfe84ac 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -7,6 +7,7 @@ print_no_existing_profile_message, ) + class Code42Profile(object): def __init__(self, profile): self._profile = profile diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 6f8d439b3..9fb1be769 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -66,7 +66,7 @@ def print_set_default_profile_help(existing_profiles): print( u"\nNo default profile set.\n", u"\nUse the --profile flag to specify which profile to use.\n", - u"\nTo set the default profile (used whenever --profile argument is not provided), use:" + u"\nTo set the default profile (used whenever --profile argument is not provided), use:", ) print_bold(u"\tcode42 profile use ") print(u"\nExisting profiles:") diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 5701db86c..29d070a2f 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -122,7 +122,7 @@ def test_update_profile_updates_existing_profile( profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile - profilecmd.update_profile(name, "bar", "baz", True) + profilecmd.update_profile(name=name, server="bar", username="baz", disable_ssl_errors=True) mock_cliprofile_namespace.update_profile.assert_called_once_with(name, "bar", "baz", True) diff --git a/tests/test_profile.py b/tests/test_profile.py index 7df50096e..2a1823037 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -58,6 +58,7 @@ def test_get_profile_returns_expected_profile(config_accessor): profile = cliprofile.get_profile("testprofilename") assert profile.name == "testprofilename" + def test_get_profile_when_config_accessor_throws_exits(config_accessor): config_accessor.get_profile.side_effect = NoConfigProfileError() with pytest.raises(SystemExit): @@ -76,18 +77,20 @@ def test_default_profile_exists_when_not_exists_returns_false(config_accessor): assert not cliprofile.default_profile_exists() -def test_validate_default_profile_prints_set_default_help_when_no_valid_default_but_another_profile_exists(capsys, config_accessor): +def test_validate_default_profile_prints_set_default_help_when_no_valid_default_but_another_profile_exists( + capsys, config_accessor +): config_accessor.get_profile.side_effect = NoConfigProfileError() - config_accessor.get_all_profiles.return_value = [ - MockSection("thisprofilexists") - ] + config_accessor.get_all_profiles.return_value = [MockSection("thisprofilexists")] with pytest.raises(SystemExit): cliprofile.validate_default_profile() capture = capsys.readouterr() assert "No default profile set." in capture.out -def test_validate_default_profile_prints_create_profile_help_when_no_valid_default_and_no_other_profiles_exists(capsys, config_accessor): +def test_validate_default_profile_prints_create_profile_help_when_no_valid_default_and_no_other_profiles_exists( + capsys, config_accessor +): config_accessor.get_profile.side_effect = NoConfigProfileError() config_accessor.get_all_profiles.return_value = [] with pytest.raises(SystemExit): From 683633f2ad30638aeed278def65af2654859dac1 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 15 Apr 2020 09:22:58 -0500 Subject: [PATCH 028/349] Chore/agent str (#33) --- src/code42cli/__init__.py | 2 +- src/code42cli/cmds/securitydata/extraction.py | 1 + src/code42cli/main.py | 8 ++ src/code42cli/password.py | 5 +- tests/cmds/detectionlists/test_high_risk.py | 22 +++-- tests/cmds/securitydata/conftest.py | 4 +- tests/cmds/securitydata/test_cursor_store.py | 6 +- tests/cmds/securitydata/test_extraction.py | 87 ++++++++++--------- tests/cmds/securitydata/test_main.py | 5 +- tests/cmds/test_profile.py | 11 +-- tests/test_bulk.py | 21 +++-- tests/test_commands.py | 3 +- tests/test_config.py | 3 +- tests/test_password.py | 10 ++- tests/test_profile.py | 9 +- tests/test_util.py | 17 ++-- 16 files changed, 130 insertions(+), 84 deletions(-) diff --git a/src/code42cli/__init__.py b/src/code42cli/__init__.py index 8b1378917..c361115bd 100644 --- a/src/code42cli/__init__.py +++ b/src/code42cli/__init__.py @@ -1 +1 @@ - +PRODUCT_NAME = u"code42cli" diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index dd4c78435..7371860a9 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -17,6 +17,7 @@ from code42cli.compat import str from code42cli.util import is_interactive, print_bold, print_error, print_to_stderr + _EXCEPTIONS_OCCURRED = False _TOTAL_EVENTS = 0 diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 0a0f44ae6..d13838121 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,6 +1,9 @@ import platform import sys +from py42.sdk.settings import set_user_agent_suffix + +from code42cli import PRODUCT_NAME from code42cli.cmds import profile from code42cli.cmds.detectionlists import main as dlmain from code42cli.cmds.securitydata import main as secmain @@ -19,6 +22,11 @@ windll.kernel32.SetConsoleMode(c_int(stdout_handle), mode) +# Sets part of the user agent string that py42 attaches to requests for the purposes of +# identifying CLI users. +set_user_agent_suffix(PRODUCT_NAME) + + def main(): top = Command(u"", u"", subcommand_loader=_load_top_commands) invoker = CommandInvoker(top) diff --git a/src/code42cli/password.py b/src/code42cli/password.py index 9270342b1..1a370e848 100644 --- a/src/code42cli/password.py +++ b/src/code42cli/password.py @@ -4,10 +4,9 @@ import keyring +from code42cli import PRODUCT_NAME from code42cli.util import does_user_agree -_ROOT_SERVICE_NAME = u"code42cli" - def get_stored_password(profile): """Gets your currently stored password for the given profile name.""" @@ -31,7 +30,7 @@ def set_password(profile, new_password): def _get_keyring_service_name(profile_name): - return u"{}::{}".format(_ROOT_SERVICE_NAME, profile_name) + return u"{}::{}".format(PRODUCT_NAME, profile_name) def _prompt_for_alternative_store(): diff --git a/tests/cmds/detectionlists/test_high_risk.py b/tests/cmds/detectionlists/test_high_risk.py index c93879698..525f27370 100644 --- a/tests/cmds/detectionlists/test_high_risk.py +++ b/tests/cmds/detectionlists/test_high_risk.py @@ -1,5 +1,6 @@ import pytest +from code42cli import PRODUCT_NAME from code42cli.cmds.detectionlists.high_risk import ( generate_csv_file, add_high_risk_employee, @@ -9,7 +10,16 @@ @pytest.fixture def bulk_template_generator(mocker): - return mocker.patch("code42cli.cmds.detectionlists.high_risk.generate_template") + return mocker.patch("{}.cmds.detectionlists.high_risk.generate_template".format(PRODUCT_NAME)) + + +@pytest.fixture +def bulk_processor_factory(mocker, bulk_processor): + factory = mocker.patch( + "{}.cmds.detectionlists.high_risk.create_bulk_processor".format(PRODUCT_NAME) + ) + factory.return_value = bulk_processor + return factory def test_generate_csv_file_generates_template(bulk_template_generator): @@ -18,15 +28,11 @@ def test_generate_csv_file_generates_template(bulk_template_generator): bulk_template_generator.assert_called_once_with(add_high_risk_employee, path) -def test_bulk_add_high_risk_employees_runs(mocker, bulk_processor, sdk, profile): - factory = mocker.patch("code42cli.cmds.detectionlists.high_risk.create_bulk_processor") - factory.return_value = bulk_processor +def test_bulk_add_high_risk_employees_runs(sdk, profile, bulk_processor, bulk_processor_factory): bulk_add_high_risk_employees(sdk, profile, "") assert bulk_processor.run.call_count == 1 -def test_bulk_add_high_risk_employees_creates_processor(mocker, bulk_processor, sdk, profile): - factory = mocker.patch("code42cli.cmds.detectionlists.high_risk.create_bulk_processor") - factory.return_value = bulk_processor +def test_bulk_add_high_risk_employees_creates_processor(sdk, profile, bulk_processor_factory): bulk_add_high_risk_employees(sdk, profile, "csv_test") - assert factory.call_args[0][0] == "csv_test" + assert bulk_processor_factory.call_args[0][0] == "csv_test" diff --git a/tests/cmds/securitydata/conftest.py b/tests/cmds/securitydata/conftest.py index 13a28997e..8f8aee439 100644 --- a/tests/cmds/securitydata/conftest.py +++ b/tests/cmds/securitydata/conftest.py @@ -3,7 +3,9 @@ import pytest -SECURITYDATA_NAMESPACE = "code42cli.cmds.securitydata" +from code42cli import PRODUCT_NAME + +SECURITYDATA_NAMESPACE = "{}.cmds.securitydata".format(PRODUCT_NAME) def get_filter_value_from_json(json, filter_index): diff --git a/tests/cmds/securitydata/test_cursor_store.py b/tests/cmds/securitydata/test_cursor_store.py index 4a391b605..c3d9819fc 100644 --- a/tests/cmds/securitydata/test_cursor_store.py +++ b/tests/cmds/securitydata/test_cursor_store.py @@ -1,6 +1,8 @@ from os import path from c42eventextractor.extractors import INSERTION_TIMESTAMP_FIELD_NAME + +from code42cli import PRODUCT_NAME from code42cli.cmds.shared.cursor_store import BaseCursorStore, FileEventCursorStore @@ -27,7 +29,9 @@ class TestFileEventCursorStore(object): def test_init_when_called_twice_with_different_profile_names_creates_two_rows( self, mocker, sqlite_connection ): - mock = mocker.patch("code42cli.cmds.shared.cursor_store.FileEventCursorStore._row_exists") + mock = mocker.patch( + "{}.cmds.shared.cursor_store.FileEventCursorStore._row_exists".format(PRODUCT_NAME) + ) mock.return_value = False spy = mocker.spy(FileEventCursorStore, "_insert_new_row") FileEventCursorStore("Profile A", self.MOCK_TEST_DB_NAME) diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index 901c035d6..98f5ef15d 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -1,9 +1,11 @@ -import code42cli.cmds.securitydata.extraction as extraction_module import pytest -from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions + from py42.sdk import SDKClient from py42.sdk.queries.fileevents.filters import * +from code42cli import PRODUCT_NAME +from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions +import code42cli.cmds.securitydata.extraction as extraction_module from .conftest import ( SECURITYDATA_NAMESPACE, begin_date_str, @@ -34,6 +36,11 @@ def error_logger(mocker): return mocker.patch("{0}.extraction.get_error_logger".format(SECURITYDATA_NAMESPACE)) +@pytest.fixture +def error_printer(mocker): + return mocker.patch("{}.cmds.securitydata.extraction.print_error".format(PRODUCT_NAME)) + + @pytest.fixture def extractor(mocker): mock = mocker.MagicMock() @@ -50,6 +57,32 @@ def namespace_with_begin(namespace): return namespace +@pytest.fixture +def is_interactive_function(mocker): + return mocker.patch("{}.cmds.securitydata.extraction.is_interactive".format(PRODUCT_NAME)) + + +@pytest.fixture +def interactive_mode(is_interactive_function): + is_interactive_function.return_value = True + return is_interactive_function + + +@pytest.fixture +def non_interactive_mode(is_interactive_function): + is_interactive_function.return_value = False + return is_interactive_function + + +@pytest.fixture +def checkpoint(mocker): + return mocker.patch( + "{}.cmds.shared.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp".format( + PRODUCT_NAME + ) + ) + + def filter_term_is_in_call_args(extractor, term): arg_filters = extractor.extract.call_args[0] for f in arg_filters: @@ -292,27 +325,21 @@ def test_extract_when_end_date_is_before_begin_date_causes_exit( def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - mocker, sdk, profile, logger, namespace, extractor + sdk, profile, logger, namespace, extractor, checkpoint ): namespace.begin = "2019-01-01" namespace.incremental = True - mock_checkpoint = mocker.patch( - "code42cli.cmds.shared.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" - ) - mock_checkpoint.return_value = 22624624 + checkpoint.return_value = 22624624 extraction_module.extract(sdk, profile, logger, namespace) assert not filter_term_is_in_call_args(extractor, EventTimestamp._term) def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( - mocker, sdk, profile, logger, namespace, extractor + sdk, profile, logger, namespace, extractor ): namespace.begin = get_test_date_str(days_ago=1) namespace.incremental = False - mock_checkpoint = mocker.patch( - "code42cli.cmds.shared.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" - ) - mock_checkpoint.return_value = 22624624 + checkpoint.return_value = 22624624 extraction_module.extract(sdk, profile, logger, namespace) actual_ts = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) @@ -322,14 +349,11 @@ def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_b def test_when_not_given_begin_date_and_is_incremental_but_no_stored_checkpoint_exists_causes_exit( - mocker, sdk, profile, logger, namespace, extractor + sdk, profile, logger, namespace, extractor ): namespace.begin = None namespace.is_incremental = True - mock_checkpoint = mocker.patch( - "code42cli.cmds.shared.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp" - ) - mock_checkpoint.return_value = None + checkpoint.return_value = None with pytest.raises(SystemExit): extraction_module.extract(sdk, profile, logger, namespace) @@ -492,42 +516,27 @@ def side_effect(): def test_extract_when_global_variable_is_true_and_is_interactive_prints_error( - mocker, sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, namespace_with_begin, extractor, error_printer, interactive_mode ): - mock_error_printer = mocker.patch("code42cli.cmds.securitydata.extraction.print_error") - mock_is_interactive_function = mocker.patch( - "code42cli.cmds.securitydata.extraction.is_interactive" - ) - mock_is_interactive_function.return_value = True extraction_module._EXCEPTIONS_OCCURRED = True extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert mock_error_printer.call_count + assert error_printer.call_count def test_extract_when_global_variable_is_true_and_not_is_interactive_does_not_print_error( - mocker, sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, namespace_with_begin, extractor, error_printer, non_interactive_mode ): - mock_error_printer = mocker.patch("code42cli.cmds.securitydata.extraction.print_error") - mock_is_interactive_function = mocker.patch( - "code42cli.cmds.securitydata.extraction.is_interactive" - ) - mock_is_interactive_function.return_value = False extraction_module._EXCEPTIONS_OCCURRED = True extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert not mock_error_printer.call_count + assert not error_printer.call_count def test_extract_when_global_variable_is_false_and_is_interactive_does_not_print_error( - mocker, sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, namespace_with_begin, extractor, error_printer, interactive_mode ): - mock_error_printer = mocker.patch("code42cli.cmds.securitydata.extraction.print_error") - mock_is_interactive_function = mocker.patch( - "code42cli.cmds.securitydata.extraction.is_interactive" - ) - mock_is_interactive_function.return_value = True extraction_module._EXCEPTIONS_OCCURRED = False extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert not mock_error_printer.call_count + assert not error_printer.call_count def test_when_sdk_raises_exception_global_variable_gets_set( @@ -537,7 +546,7 @@ def test_when_sdk_raises_exception_global_variable_gets_set( mock_sdk = mocker.MagicMock() # For ease - mock = mocker.patch("code42cli.cmds.securitydata.extraction.is_interactive") + mock = mocker.patch("{}.cmds.securitydata.extraction.is_interactive".format(PRODUCT_NAME)) mock.return_value = False def sdk_side_effect(self, *args): diff --git a/tests/cmds/securitydata/test_main.py b/tests/cmds/securitydata/test_main.py index cf7616389..b1dafcf52 100644 --- a/tests/cmds/securitydata/test_main.py +++ b/tests/cmds/securitydata/test_main.py @@ -1,15 +1,16 @@ +from code42cli import PRODUCT_NAME import code42cli.cmds.securitydata.main as main import pytest @pytest.fixture def mock_logger_factory(mocker): - return mocker.patch("code42cli.cmds.securitydata.main.logger_factory") + return mocker.patch("{}.cmds.securitydata.main.logger_factory".format(PRODUCT_NAME)) @pytest.fixture def mock_extract(mocker): - return mocker.patch("code42cli.cmds.securitydata.main.extract") + return mocker.patch("{}.cmds.securitydata.main.extract".format(PRODUCT_NAME)) def test_print_out(sdk, profile, namespace, mocker, mock_logger_factory, mock_extract): diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 29d070a2f..59d740181 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,37 +1,38 @@ import code42cli.cmds.profile as profilecmd import pytest +from code42cli import PRODUCT_NAME from ..conftest import create_mock_profile @pytest.fixture def user_agreement(mocker): - mock = mocker.patch("code42cli.cmds.profile.does_user_agree") + mock = mocker.patch("{}.cmds.profile.does_user_agree".format(PRODUCT_NAME)) mock.return_value = True return mocker @pytest.fixture def user_disagreement(mocker): - mock = mocker.patch("code42cli.cmds.profile.does_user_agree") + mock = mocker.patch("{}.cmds.profile.does_user_agree".format(PRODUCT_NAME)) mock.return_value = False return mocker @pytest.fixture def mock_cliprofile_namespace(mocker): - return mocker.patch("code42cli.cmds.profile.cliprofile") + return mocker.patch("{}.cmds.profile.cliprofile".format(PRODUCT_NAME)) @pytest.fixture(autouse=True) def mock_getpass(mocker): - mock = mocker.patch("code42cli.cmds.profile.getpass") + mock = mocker.patch("{}.cmds.profile.getpass".format(PRODUCT_NAME)) mock.return_value = "newpassword" @pytest.fixture def mock_verify(mocker): - return mocker.patch("code42cli.cmds.profile.validate_connection") + return mocker.patch("{}.cmds.profile.validate_connection".format(PRODUCT_NAME)) @pytest.fixture diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 76b76eff9..9b1487003 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -1,15 +1,22 @@ +import pytest from io import IOBase +from code42cli import PRODUCT_NAME from code42cli.bulk import generate_template, BulkProcessor -def test_generate_template_uses_expected_path_and_column_names(mocker): +@pytest.fixture +def mock_open(mocker): + mock = mocker.patch("{}.bulk.open".format(PRODUCT_NAME)) + mock.return_value = mocker.MagicMock(spec=IOBase) + return mock + + +def test_generate_template_uses_expected_path_and_column_names(mocker, mock_open): def func_for_bulk(sdk, profile, test1, test2): pass file_path = "some/path" - mock_open = mocker.patch("code42cli.bulk.open") - mock_open.return_value = mocker.MagicMock(spec=IOBase) template_file = mock_open.return_value.__enter__.return_value generate_template(func_for_bulk, file_path) @@ -17,21 +24,19 @@ def func_for_bulk(sdk, profile, test1, test2): template_file.write.assert_called_once_with("test1,test2") -def test_generate_template_when_given_non_callable_handler_does_not_create(mocker): - mock_open = mocker.patch("code42cli.bulk.open") +def test_generate_template_when_given_non_callable_handler_does_not_create(mock_open): generate_template(None, "some/path") assert not mock_open.call_count class TestBulkProcessor(object): - def test_run_processes_rows(self, mocker): + def test_run_processes_rows(self, mocker, mock_open): processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - mocker.patch("code42cli.bulk.open") - dict_reader = mocker.patch("code42cli.bulk._create_dict_reader") + dict_reader = mocker.patch("{}.bulk._create_dict_reader".format(PRODUCT_NAME)) dict_reader.return_value = [ {"test1": 1, "test2": 2}, {"test1": 3, "test2": 4}, diff --git a/tests/test_commands.py b/tests/test_commands.py index 6adf51425..182341426 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,4 +1,5 @@ import pytest +from code42cli import PRODUCT_NAME from code42cli.args import ArgConfig from code42cli.commands import Command, DictObject from code42cli.profile import Code42Profile @@ -27,7 +28,7 @@ def arg_customizer(arg_collection): @pytest.fixture def mock_profile_reader(mocker): - return mocker.patch("code42cli.profile.get_profile") + return mocker.patch("{}.profile.get_profile".format(PRODUCT_NAME)) @pytest.fixture diff --git a/tests/test_config.py b/tests/test_config.py index 0cf0ce7c7..b6beb8919 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ import pytest from configparser import ConfigParser +from code42cli import PRODUCT_NAME from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection @@ -56,7 +57,7 @@ def side_effect(): @pytest.fixture(autouse=True) def mock_saver(mocker): - return mocker.patch("code42cli.util.open_file") + return mocker.patch("{}.util.open_file".format(PRODUCT_NAME)) def create_mock_profile_object(profile_name, authority_url=None, username=None): diff --git a/tests/test_password.py b/tests/test_password.py index 931b544f2..1ebc60fd5 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,6 +1,8 @@ import code42cli.password as password import pytest +from code42cli import PRODUCT_NAME + _USERNAME = "test.username" @@ -23,19 +25,19 @@ def get_keyring(mocker): @pytest.fixture def getpass_function(mocker): - return mocker.patch("code42cli.password.getpass") + return mocker.patch("{}.password.getpass".format(PRODUCT_NAME)) @pytest.fixture def user_agreement(mocker): - mock = mocker.patch("code42cli.password.does_user_agree") + mock = mocker.patch("{}.password.does_user_agree".format(PRODUCT_NAME)) mock.return_value = True return mocker @pytest.fixture def user_disagreement(mocker): - mock = mocker.patch("code42cli.password.does_user_agree") + mock = mocker.patch("{}.password.does_user_agree".format(PRODUCT_NAME)) mock.return_value = False return mocker @@ -45,7 +47,7 @@ def test_get_stored_password_when_given_profile_name_gets_profile_for_that_name( ): profile.name = "foo" profile.username = "bar" - service_name = "code42cli::{}".format(profile.name) + service_name = "{}::{}".format(PRODUCT_NAME, profile.name) password.get_stored_password(profile) keyring_password_getter.assert_called_once_with(service_name, profile.username) diff --git a/tests/test_profile.py b/tests/test_profile.py index 2a1823037..02a347208 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,5 +1,6 @@ import pytest +from code42cli import PRODUCT_NAME import code42cli.profile as cliprofile from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection, create_mock_profile @@ -8,24 +9,24 @@ @pytest.fixture def config_accessor(mocker): mock = mocker.MagicMock(spec=ConfigAccessor, name="Config Accessor") - attr = mocker.patch("code42cli.profile.config_accessor", mock) + attr = mocker.patch("{}.profile.config_accessor".format(PRODUCT_NAME), mock) return attr @pytest.fixture def password_setter(mocker): - return mocker.patch("code42cli.password.set_password") + return mocker.patch("{}.password.set_password".format(PRODUCT_NAME)) @pytest.fixture def password_getter(mocker): - return mocker.patch("code42cli.password.get_stored_password") + return mocker.patch("{}.password.get_stored_password".format(PRODUCT_NAME)) class TestCode42Profile(object): def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter): password_getter.return_value = None - mock_getpass = mocker.patch("code42cli.password.get_password_from_prompt") + mock_getpass = mocker.patch("{}.password.get_password_from_prompt".format(PRODUCT_NAME)) mock_getpass.return_value = "Test Password" actual = create_mock_profile().get_password() assert actual == "Test Password" diff --git a/tests/test_util.py b/tests/test_util.py index df95aa703..f5c0eac03 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,14 @@ +import pytest + +from code42cli import PRODUCT_NAME from code42cli.util import does_user_agree, get_url_parts +@pytest.fixture +def mock_input(mocker): + return mocker.patch("{}.util.get_input".format(PRODUCT_NAME)) + + def test_get_url_parts_when_given_host_and_port_returns_expected_parts(): url_str = "www.example.com:123" parts = get_url_parts(url_str) @@ -13,19 +21,16 @@ def test_get_url_parts_when_given_host_without_port_returns_expected_parts(): assert parts == ("www.example.com", None) -def test_does_user_agree_when_user_says_y_returns_true(mocker): - mock_input = mocker.patch("code42cli.util.get_input") +def test_does_user_agree_when_user_says_y_returns_true(mock_input): mock_input.return_value = "y" assert does_user_agree("Test Prompt") -def test_does_user_agree_when_user_says_capital_y_returns_true(mocker): - mock_input = mocker.patch("code42cli.util.get_input") +def test_does_user_agree_when_user_says_capital_y_returns_true(mock_input): mock_input.return_value = "Y" assert does_user_agree("Test Prompt") -def test_does_user_agree_when_user_says_n_returns_false(mocker): - mock_input = mocker.patch("code42cli.util.get_input") +def test_does_user_agree_when_user_says_n_returns_false(mock_input): mock_input.return_value = "n" assert not does_user_agree("Test Prompt") From bbc128c984e7ca5083d591a38e7767646194812c Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Wed, 15 Apr 2020 09:35:23 -0500 Subject: [PATCH 029/349] fix write-to (#34) --- src/code42cli/cmds/securitydata/main.py | 2 +- src/code42cli/config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index 495ba8a1d..e9d52ccf9 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -74,7 +74,7 @@ def send_to(sdk, profile, args): def _load_write_to_args(arg_collection): output_file = ArgConfig(u"output_file", help=u"The name of the local file to send output to.") - arg_collection.add(u"output_file", output_file) + arg_collection.append(u"output_file", output_file) _load_search_args(arg_collection) diff --git a/src/code42cli/config.py b/src/code42cli/config.py index dc0159c29..cfc057ecc 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -10,7 +10,7 @@ class NoConfigProfileError(Exception): def __init__(self): - super(Exception, self).__init__(u"Profile does not exist.") + super(NoConfigProfileError, self).__init__(u"Profile does not exist.") class ConfigAccessor(object): From 9f0549fad20664a15e7bf47a704bb330daefb941 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 15 Apr 2020 15:30:10 -0500 Subject: [PATCH 030/349] Feature/profile deletion (#30) * -Add validation logic around default profile. -Add help message for when default is not set (or points to an invalid/missing entry). * - update base cursor store to allow row deletions. - add clean() method to FileEventCursorStore to remove a profile's row(s). * add delete_password() to password module * add delete_profile() to ConfigAccessor * handle resetting default_profile internal value to __DEFAULT__ when the default profile is deleted. * add delete_profile() method to profile module that clears config/password/cursor_store * - add `delete_profile()`, `delete_all_profiles()` functions - add `delete`, and `delete_all` Commands and register them - fix typo in `_load_profile_create_descriptions()` * merge error * fix more merge errors * fix the merge error fix error * address PR feedback * update changelog * remove unused DEFAULT_PROFILE_IS_COMPLETE config item. * attempting some tests * a working delete_profile_clears_checkpoint test * fix test for py3.5 compat * formatting --- CHANGELOG.md | 3 ++ src/code42cli/cmds/profile.py | 41 +++++++++++++++++- src/code42cli/cmds/shared/cursor_store.py | 15 +++++++ src/code42cli/config.py | 11 ++++- src/code42cli/password.py | 6 +++ src/code42cli/profile.py | 16 +++++++ tests/cmds/securitydata/test_cursor_store.py | 12 ++++++ tests/cmds/test_profile.py | 45 ++++++++++++++++++++ tests/test_config.py | 4 -- tests/test_profile.py | 28 ++++++++++++ 10 files changed, 174 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4538c94..f89357482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,14 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `--filename` flag renamed to `--file-name`. - `--filepath` flag renamed to `--file-path`. - `--processOwner` flag renamed to `--process-owner` +- Default profile validation logic added to prevent confusing error states. ### Added - `code42 profile update` command. - `code42 profile create` command. +- `code42 profile delete` command. +- `code42 profile delete-all` command. - `code42 detection-lists high-risk` commands: - `bulk` with subcommands: - `add`: that takes a csv file of users. diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 997636f14..3608cd924 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -59,7 +59,21 @@ def load_subcommands(): arg_customizer=_load_profile_update_descriptions, ) - return [show, list_all, use, reset_pw, create, update] + delete = Command( + u"delete", + "Deletes a profile and its stored password (if any).", + u"{} {}".format(usage_prefix, u"delete "), + handler=delete_profile, + ) + + delete_all = Command( + u"delete-all", + u"Deletes all profiles and saved passwords (if any).", + u"{} {}".format(usage_prefix, u"delete-all"), + handler=delete_all_profiles, + ) + + return [show, list_all, use, reset_pw, create, update, delete, delete_all] def show_profile(name=None): @@ -117,6 +131,31 @@ def use_profile(profile): cliprofile.switch_default_profile(profile) +def delete_profile(name): + if cliprofile.is_default_profile(name): + print(u"\n{} is currently the default profile!".format(name)) + if not does_user_agree( + u"\nDeleting this profile will also delete any stored passwords and checkpoints. Are you sure? (y/n): " + ): + return + cliprofile.delete_profile(name) + + +def delete_all_profiles(): + existing_profiles = cliprofile.get_all_profiles() + if existing_profiles: + print(u"\nAre you sure you want to delete the following profiles?") + for profile in existing_profiles: + print(u"\t{}".format(profile.name)) + if does_user_agree( + u"\nThis will also delete any stored passwords and checkpoints. (y/n): " + ): + for profile in existing_profiles: + cliprofile.delete_profile(profile.name) + else: + print(u"\nNo profiles exist. Nothing to delete.") + + def _load_optional_profile_description(argument_collection): profile = argument_collection.arg_configs[u"name"] profile.add_short_option_name(u"-n") diff --git a/src/code42cli/cmds/shared/cursor_store.py b/src/code42cli/cmds/shared/cursor_store.py index b3db2726f..f8837bb97 100644 --- a/src/code42cli/cmds/shared/cursor_store.py +++ b/src/code42cli/cmds/shared/cursor_store.py @@ -33,6 +33,13 @@ def _set(self, column_name, new_value, primary_key): with self._connection as conn: conn.execute(query, (new_value, primary_key)) + def _delete(self, primary_key): + query = u"DELETE FROM {0} WHERE {1}=?".format( + self._table_name, self._PRIMARY_KEY_COLUMN_NAME + ) + with self._connection as conn: + conn.execute(query, (primary_key,)) + def _row_exists(self, primary_key): query = u"SELECT * FROM {0} WHERE {1}=?" query = query.format(self._table_name, self._PRIMARY_KEY_COLUMN_NAME) @@ -86,6 +93,10 @@ def replace_stored_insertion_timestamp(self, new_insertion_timestamp): primary_key=self._primary_key, ) + def clean(self): + """Removes profile cursor data from store.""" + self._delete(self._primary_key) + def _init_table(self): columns = u"{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, _INSERTION_TIMESTAMP_FIELD_NAME) create_table_query = u"CREATE TABLE {0} ({1})".format(self._table_name, columns) @@ -96,3 +107,7 @@ def _insert_new_row(self): insert_query = u"INSERT INTO {0} VALUES(?, null)".format(self._table_name) with self._connection as conn: conn.execute(insert_query, (self._primary_key,)) + + +def get_file_event_cursor_store(profile_name): + return FileEventCursorStore(profile_name) diff --git a/src/code42cli/config.py b/src/code42cli/config.py index cfc057ecc..5ec120827 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -18,7 +18,6 @@ class ConfigAccessor(object): AUTHORITY_KEY = u"c42_authority_url" USERNAME_KEY = u"c42_username" IGNORE_SSL_ERRORS_KEY = u"ignore-ssl-errors" - DEFAULT_PROFILE_IS_COMPLETE = u"default_profile_is_complete" DEFAULT_PROFILE = u"default_profile" _INTERNAL_SECTION = u"Internal" @@ -81,6 +80,15 @@ def switch_default_profile(self, new_default_name): self._save() print(u"{} has been set as the default profile.".format(new_default_name)) + def delete_profile(self, name): + """Deletes a profile.""" + if self.get_profile(name) is None: + raise NoConfigProfileError() + self.parser.remove_section(name) + if name == self._default_profile_name: + self._internal[self.DEFAULT_PROFILE] = self.DEFAULT_VALUE + self._save() + def _set_authority_url(self, new_value, profile): profile[self.AUTHORITY_KEY] = new_value.strip() @@ -112,7 +120,6 @@ def _get_profile_names(self): def _create_internal_section(self): self.parser.add_section(self._INTERNAL_SECTION) self.parser[self._INTERNAL_SECTION] = {} - self.parser[self._INTERNAL_SECTION][self.DEFAULT_PROFILE_IS_COMPLETE] = str(False) self.parser[self._INTERNAL_SECTION][self.DEFAULT_PROFILE] = self.DEFAULT_VALUE def _create_profile_section(self, name): diff --git a/src/code42cli/password.py b/src/code42cli/password.py index 1a370e848..d57d86be7 100644 --- a/src/code42cli/password.py +++ b/src/code42cli/password.py @@ -29,6 +29,12 @@ def set_password(profile, new_password): keyring.set_password(service_name, profile.username, new_password) +def delete_password(profile): + """Deletes password for the given profile name.""" + service_name = _get_keyring_service_name(profile.name) + keyring.delete_password(service_name, profile.username) + + def _get_keyring_service_name(profile_name): return u"{}::{}".format(PRODUCT_NAME, profile_name) diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index a7cfe84ac..2f6807508 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -1,4 +1,5 @@ import code42cli.password as password +from code42cli.cmds.shared.cursor_store import get_file_event_cursor_store from code42cli.config import ConfigAccessor, config_accessor, NoConfigProfileError from code42cli.util import ( print_error, @@ -70,6 +71,12 @@ def default_profile_exists(): return False +def is_default_profile(name): + if default_profile_exists(): + default = get_profile() + return name == default.name + + def validate_default_profile(): if not default_profile_exists(): existing_profiles = get_all_profiles() @@ -100,6 +107,15 @@ def create_profile(name, server, username, ignore_ssl_errors): config_accessor.create_profile(name, server, username, ignore_ssl_errors) +def delete_profile(profile_name): + profile = _get_profile(profile_name) + if password.get_stored_password(profile) is not None: + password.delete_password(profile) + cursor_store = get_file_event_cursor_store(profile_name) + cursor_store.clean() + config_accessor.delete_profile(profile_name) + + def update_profile(name, server, username, ignore_ssl_errors): config_accessor.update_profile(name, server, username, ignore_ssl_errors) diff --git a/tests/cmds/securitydata/test_cursor_store.py b/tests/cmds/securitydata/test_cursor_store.py index c3d9819fc..6c5cee92a 100644 --- a/tests/cmds/securitydata/test_cursor_store.py +++ b/tests/cmds/securitydata/test_cursor_store.py @@ -79,3 +79,15 @@ def test_replace_stored_insertion_timestamp_executes_query_with_expected_primary with store._connection as conn: actual = conn.execute.call_args[0][1][0] assert actual == new_insertion_timestamp + + def test_clean_executes_query_with_expected_primary_key(self, sqlite_connection): + profile_name = "Profile" + store = FileEventCursorStore(profile_name, self.MOCK_TEST_DB_NAME) + store.clean() + with store._connection as conn: + expected_query = "DELETE FROM {0} WHERE {1}=?".format( + store._table_name, store._PRIMARY_KEY_COLUMN_NAME + ) + actual_query, pk = conn.execute.call_args[0] + assert expected_query == actual_query + assert pk == (profile_name,) diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 59d740181..ab47c64ac 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -163,6 +163,51 @@ def test_update_profile_if_user_agrees_and_valid_connection_sets_password( mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) +def test_delete_profile_warns_if_deleting_default( + capsys, user_agreement, mock_cliprofile_namespace +): + mock_cliprofile_namespace.is_default_profile.return_value = True + profilecmd.delete_profile("mockdefault") + capture = capsys.readouterr() + assert "mockdefault is currently the default profile!" in capture.out + + +def test_delete_all_warns_if_profiles_exist(capsys, user_agreement, mock_cliprofile_namespace): + mock_cliprofile_namespace.get_all_profiles.return_value = [ + create_mock_profile("test1"), + create_mock_profile("test2"), + ] + profilecmd.delete_all_profiles() + capture = capsys.readouterr() + assert "Are you sure you want to delete the following profiles?" in capture.out + assert "test1" in capture.out + assert "test2" in capture.out + + +def test_delete_profile_does_nothing_if_user_doesnt_agree( + user_disagreement, mock_cliprofile_namespace +): + profilecmd.delete_profile("mockprofile") + assert mock_cliprofile_namespace.delete_profile.call_count == 0 + + +def test_delete_all_profiles_does_nothing_if_user_doesnt_agree( + user_disagreement, mock_cliprofile_namespace +): + profilecmd.delete_all_profiles() + assert mock_cliprofile_namespace.delete_profile.call_count == 0 + + +def test_delete_all_deletes_all_existing_profiles(user_agreement, mock_cliprofile_namespace): + mock_cliprofile_namespace.get_all_profiles.return_value = [ + create_mock_profile("test1"), + create_mock_profile("test2"), + ] + profilecmd.delete_all_profiles() + mock_cliprofile_namespace.delete_profile.assert_any_call("test1") + mock_cliprofile_namespace.delete_profile.assert_any_call("test2") + + def test_prompt_for_password_reset_if_credentials_valid_password_saved( mocker, user_agreement, mock_verify, mock_cliprofile_namespace ): diff --git a/tests/test_config.py b/tests/test_config.py index b6beb8919..7e956275e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -71,7 +71,6 @@ def create_internal_object(is_complete, default_profile_name=None): default_profile_name = default_profile_name or ConfigAccessor.DEFAULT_VALUE internal_dict = { ConfigAccessor.DEFAULT_PROFILE: default_profile_name, - ConfigAccessor.DEFAULT_PROFILE_IS_COMPLETE: is_complete, } internal_section = MockSection(_INTERNAL, internal_dict) @@ -164,7 +163,6 @@ def test_create_profile_when_no_default_profile_sets_default( ): mock_profile = create_mock_profile_object(_TEST_PROFILE_NAME, None, None) mock_internal = create_internal_object(False) - mock_internal["default_profile_is_complete"] = "False" setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() @@ -187,7 +185,6 @@ def test_create_profile_when_has_default_profile_does_not_set_default( def test_create_profile_when_not_existing_saves(self, config_parser_for_create, mock_saver): create_mock_profile_object(_TEST_PROFILE_NAME, None, None) mock_internal = create_internal_object(False) - mock_internal["default_profile_is_complete"] = "False" setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) @@ -198,7 +195,6 @@ def test_create_profile_when_not_existing_outputs_confirmation( self, capsys, config_parser_for_create, mock_saver ): mock_internal = create_internal_object(False) - mock_internal["default_profile_is_complete"] = "False" setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) diff --git a/tests/test_profile.py b/tests/test_profile.py index 02a347208..557510ad3 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -3,6 +3,7 @@ from code42cli import PRODUCT_NAME import code42cli.profile as cliprofile from code42cli.config import ConfigAccessor, NoConfigProfileError +from code42cli.cmds.shared.cursor_store import FileEventCursorStore from .conftest import MockSection, create_mock_profile @@ -23,6 +24,11 @@ def password_getter(mocker): return mocker.patch("{}.password.get_stored_password".format(PRODUCT_NAME)) +@pytest.fixture +def password_deleter(mocker): + return mocker.patch("code42cli.password.delete_password") + + class TestCode42Profile(object): def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter): password_getter.return_value = None @@ -182,3 +188,25 @@ def test_set_password_uses_expected_password(config_accessor, password_setter): test_profile = "testprofilename" cliprofile.set_password("newpassword", test_profile) assert password_setter.call_args[0][1] == "newpassword" + + +def test_delete_profile_deletes_password_if_exists( + config_accessor, mocker, password_getter, password_deleter +): + profile = create_mock_profile("deleteme") + mock_get_profile = mocker.patch("code42cli.profile._get_profile") + mock_get_profile.return_value = profile + password_getter.return_value = "i_exist" + cliprofile.delete_profile("deleteme") + password_deleter.assert_called_once_with(profile) + + +def test_delete_profile_clears_checkpoint(config_accessor, mocker): + profile = create_mock_profile("deleteme") + mock_get_profile = mocker.patch("code42cli.profile._get_profile") + mock_get_profile.return_value = profile + store = mocker.MagicMock(spec=FileEventCursorStore) + mock_get_cursor_store = mocker.patch("code42cli.profile.get_file_event_cursor_store") + mock_get_cursor_store.return_value = store + cliprofile.delete_profile("deleteme") + assert store.clean.call_count == 1 From c6340a478cea1c21d39ff15af7a352d505bc6dcf Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 17 Apr 2020 12:04:19 -0500 Subject: [PATCH 031/349] Bugfix/feedback after update (#38) --- src/code42cli/cmds/profile.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 3608cd924..c0b724ec6 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -3,7 +3,7 @@ from getpass import getpass import code42cli.profile as cliprofile -from code42cli.args import PROFILE_HELP, ArgConfig +from code42cli.args import PROFILE_HELP from code42cli.commands import Command from code42cli.sdk_client import validate_connection from code42cli.util import does_user_agree, print_error, print_no_existing_profile_message @@ -97,7 +97,8 @@ def update_profile(name=None, server=None, username=None, disable_ssl_errors=Non profile = cliprofile.get_profile(name) cliprofile.update_profile(profile.name, server, username, disable_ssl_errors) _prompt_for_allow_password_set(profile.name) - + print(u"Profile '{}' has been updated.".format(profile.name)) + def prompt_for_password_reset(name=None): """Securely prompts for your password and then stores it using keyring.""" From 70e3bf9a10e1c1768e5d07f906da94c0abb188eb Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 17 Apr 2020 14:39:26 -0500 Subject: [PATCH 032/349] Remove detection lists root command + additional shared code (#35) --- CHANGELOG.md | 2 +- setup.py | 4 +- src/code42cli/args.py | 20 ++- src/code42cli/bulk.py | 16 +- src/code42cli/cmds/detectionlists/__init__.py | 168 ++++++++++++++++++ src/code42cli/cmds/detectionlists/commands.py | 4 +- src/code42cli/cmds/detectionlists/enums.py | 9 +- .../cmds/detectionlists/high_risk.py | 67 ------- .../cmds/detectionlists/high_risk_employee.py | 57 ++++++ src/code42cli/cmds/detectionlists/main.py | 12 -- src/code42cli/cmds/securitydata/main.py | 12 +- src/code42cli/commands.py | 3 +- src/code42cli/invoker.py | 5 +- src/code42cli/main.py | 14 +- src/code42cli/sdk_client.py | 4 +- tests/cmds/detectionlists/conftest.py | 16 ++ tests/cmds/detectionlists/test_high_risk.py | 38 ---- .../detectionlists/test_high_risk_employee.py | 63 +++++++ tests/cmds/detectionlists/test_init.py | 78 ++++++++ tests/cmds/test_profile.py | 18 +- tests/conftest.py | 5 - tests/test_args.py | 11 ++ tests/test_bulk.py | 38 +++- tests/test_commands.py | 11 +- tests/test_config.py | 4 +- tests/test_sdk_client.py | 4 +- 26 files changed, 499 insertions(+), 184 deletions(-) delete mode 100644 src/code42cli/cmds/detectionlists/high_risk.py create mode 100644 src/code42cli/cmds/detectionlists/high_risk_employee.py delete mode 100644 src/code42cli/cmds/detectionlists/main.py create mode 100644 tests/cmds/detectionlists/conftest.py delete mode 100644 tests/cmds/detectionlists/test_high_risk.py create mode 100644 tests/cmds/detectionlists/test_high_risk_employee.py create mode 100644 tests/cmds/detectionlists/test_init.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f89357482..f0b61f88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 profile create` command. - `code42 profile delete` command. - `code42 profile delete-all` command. -- `code42 detection-lists high-risk` commands: +- `code42 high-risk-employee` commands: - `bulk` with subcommands: - `add`: that takes a csv file of users. - `generate-template`: that creates the csv file template. And parameters: diff --git a/setup.py b/setup.py index 9576b451d..82a91aeb1 100644 --- a/setup.py +++ b/setup.py @@ -21,10 +21,10 @@ package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ - "c42eventextractor==0.2.2", + "c42eventextractor==0.2.5", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42==0.6.0", + "py42==0.9.0", ], license="MIT", include_package_data=True, diff --git a/src/code42cli/args.py b/src/code42cli/args.py index 90266d29a..82e1394a8 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -8,13 +8,14 @@ class ArgConfig(object): """Stores a set of argparse commands for later use by a command.""" def __init__(self, *args, **kwargs): - self._settings = {} - self._settings[u"action"] = kwargs.get(u"action") - self._settings[u"choices"] = kwargs.get(u"choices") - self._settings[u"default"] = kwargs.get(u"default") - self._settings[u"help"] = kwargs.get(u"help") - self._settings[u"options_list"] = list(args) - self._settings[u"nargs"] = kwargs.get(u"nargs") + self._settings = { + u"action": kwargs.get(u"action"), + u"choices": kwargs.get(u"choices"), + u"default": kwargs.get(u"default"), + u"help": kwargs.get(u"help"), + u"options_list": list(args), + u"nargs": kwargs.get(u"nargs"), + } @property def settings(self): @@ -29,6 +30,9 @@ def set_help(self, help): def add_short_option_name(self, short_name): self._settings[u"options_list"].append(short_name) + def as_multi_val_param(self, nargs=u"+"): + self._settings[u"nargs"] = nargs + class ArgConfigCollection(object): def __init__(self): @@ -57,7 +61,7 @@ def get_auto_arg_configs(handler): for arg_position, key in enumerate(argspec.args): # do not create cli parameters for arguments named "sdk", "args", or "kwargs" - if not key in [u"sdk", u"args", u"kwargs"]: + if not key in [u"sdk", u"args", u"kwargs", u"self"]: arg_config = _create_auto_args_config( arg_position, key, argspec, num_args, num_kw_args ) diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 2ed25864a..0e74adba5 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -21,7 +21,12 @@ def _write_template_file(path, columns): new_csv.write(u",".join(columns)) -def create_bulk_processor(csv_file_path, row_handler): +def run_bulk_process(csv_file_path, row_handler): + processor = _create_bulk_processor(csv_file_path, row_handler) + processor.run() + + +def _create_bulk_processor(csv_file_path, row_handler): """A factory method to create the bulk processor, useful for testing purposes.""" return BulkProcessor(csv_file_path, row_handler) @@ -44,13 +49,10 @@ def __init__(self, csv_file_path, row_handler): def run(self): """Processes the csv file specified in the ctor, calling `self.row_handler` on each row.""" - rows = self._get_rows() - self._process_rows(rows) - self.__worker.wait() - - def _get_rows(self): with open(self.csv_file_path, newline=u"", encoding=u"utf8") as csv_file: - return _create_dict_reader(csv_file) + rows = _create_dict_reader(csv_file) + self._process_rows(rows) + self.__worker.wait() def _process_rows(self, rows): for row in rows: diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index e69de29bb..6b6e240f7 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -0,0 +1,168 @@ +from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory +from code42cli.bulk import generate_template, run_bulk_process +from code42cli.cmds.detectionlists.enums import ( + BulkCommandType, + DetectionLists, + DetectionListUserKeys, +) +from code42cli.util import print_error + + +class DetectionListHandlers(object): + """Handlers DTO for passing in specific detection list functions. + + Args: + add (callable): A function that adds an employee to the list. + remove (callable): A function that removes an employee from the list. + load_add (callable): A function that loads the add-related `ArgConfig`s. + """ + + def __init__(self, add=None, remove=None, load_add=None): + self.add_employee = add + self.remove_employee = remove + self.load_add_description = load_add + + +class UserDoesNotExistError(Exception): + """An error to represent a username that is not in our system.""" + + def __init__(self, username): + super(UserDoesNotExistError, self).__init__(u"User '{}' does not exist.".format(username)) + + +class DetectionList(object): + """An object representing a Code42 detection list. Use this class by passing in handlers for + adding and removing employees. This class will handle the bulk-related commands and some + shared help texts. + + Args: + list_name (str): An option from the DetectionLists enum. For convenience, use one of the + given `classmethods`. + handlers (DetectionListHandlers): A DTO containing implementations for adding / removing + users from specific lists. + cmd_factory (DetectionListCommandFactory): A factory that creates detection list commands. + """ + + def __init__(self, list_name, handlers, cmd_factory=None): + self.name = list_name + self.handlers = handlers + self.factory = cmd_factory or DetectionListCommandFactory(list_name) + + @classmethod + def create_high_risk_list(cls, handlers): + """Creates a high risk detection list. + + Args: + handlers (DetectionListHandlers): A DTO containing implementations for adding / + removing users from specific lists. + + Returns: + DetectionList: A high-risk employee detection list. + """ + return cls(DetectionLists.HIGH_RISK_EMPLOYEE, handlers) + + def load_subcommands(self): + """Loads high risk employee related subcommands""" + bulk = self.factory.create_bulk_command(lambda: self._load_bulk_subcommands()) + add = self.factory.create_add_command( + self.handlers.add_employee, self.handlers.load_add_description + ) + return [bulk, add] + + def _load_bulk_subcommands(self): + generate_template_cmd = self.factory.create_bulk_generate_template_command( + self.generate_csv_file + ) + add = self.factory.create_bulk_add_command(self.bulk_add_employees) + return [generate_template_cmd, add] + + def generate_csv_file(self, cmd, path=None): + """Generates a csv template a user would need to fill-in for bulk adding users to the + detection list. + + Args: + cmd (str): An option from the `BulkCommandType` enum specifying which type of csv to + generate. + path (str, optional): A path to put the file after it's generated. If None, will use + the current working directory. Defaults to None. + """ + handler = None + if cmd == BulkCommandType.ADD: + handler = self.handlers.add_employee + generate_template(handler, path) + + def bulk_add_employees(self, sdk, profile, csv_file): + """Takes a csv file with each row representing an employee and adds them all to a + detection list in a bulk fashion. + + Args: + sdk (py42.sdk.SDKClient): The py42 sdk. + profile (Code42Profile): The profile under which to execute this command. + csv_file (str): The path to the csv file containing rows of users. + """ + run_bulk_process(csv_file, lambda **kwargs: self._add_employee(sdk, profile, **kwargs)) + + def _add_employee(self, sdk, profile, **kwargs): + if ( + kwargs.has_key(DetectionListUserKeys.CLOUD_ALIAS) + and type(kwargs[DetectionListUserKeys.CLOUD_ALIAS]) != list + ): + kwargs[DetectionListUserKeys.CLOUD_ALIAS] = kwargs[ + DetectionListUserKeys.CLOUD_ALIAS + ].split() + + self.handlers.add_employee(sdk, profile, **kwargs) + + +def load_user_descriptions(argument_collection): + """Loads the arg descriptions related to updating fields about a detection list user, such as + notes or cloud aliases. + + Args: + argument_collection (ArgConfigCollection): The arg configs off the command that needs its + user descriptions loaded. + """ + username = argument_collection.arg_configs[DetectionListUserKeys.USERNAME] + cloud_alias = argument_collection.arg_configs[DetectionListUserKeys.CLOUD_ALIAS] + notes = argument_collection.arg_configs[DetectionListUserKeys.NOTES] + + username.set_help(u"The code42 username of the user you want to add.") + cloud_alias.set_help(u"Alternative emails addresses for other cloud services.") + cloud_alias.as_multi_val_param() + notes.set_help(u"Notes about the employee.") + + +def get_user_id(sdk, username): + """Returns the user's UID (referred to by `user_id` in detection lists). If the user does not + exist, it prints an error and exits. + + Args: + sdk (py42.sdk.SDKClient): The py42 sdk. + username (str or unicode): The username of the user to get an ID for. + + Returns: + str: The user ID for the user with the given username. + """ + users = sdk.users.get_by_username(username)[u"users"] + if not users: + print_error(str(UserDoesNotExistError(username))) + exit(1) + return users[0][u"userUid"] + + +def update_user(sdk, user_id, cloud_alias=None, risk_tag=None, notes=None): + """Updates a detection list user. + + Args: + user_id (str): The ID of the user to update. This is their `userUid` found from + `sdk.users.get_by_username()`. + cloud_alias (iter[str]): A list of cloud aliases to add to the user. + risk_tag (iter[str]): A list of risk tags associated with user. + notes (str): Notes about the user. + """ + if cloud_alias: + sdk.detectionlists.add_user_cloud_aliases(user_id, cloud_alias) + if risk_tag: + sdk.detectionlists.add_user_risk_tags(user_id, risk_tag) + if notes: + sdk.detectionlists.update_user_notes(user_id, notes) diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py index 522b30841..1f4bb6c72 100644 --- a/src/code42cli/cmds/detectionlists/commands.py +++ b/src/code42cli/cmds/detectionlists/commands.py @@ -3,7 +3,7 @@ def create_usage_prefix(detection_list_name): - return u"code42 detection-list {}".format(detection_list_name) + return u"code42 {}".format(detection_list_name) def create_bulk_usage_prefix(detection_list_name): @@ -36,7 +36,7 @@ def create_bulk_generate_template_command(self, handler): return Command( u"generate-template", u"Generate the necessary csv template needed for bulk adding users.", - u"{} gen-template ".format(self._bulk_usage_prefix), + u"{} generate-template ".format(self._bulk_usage_prefix), handler=handler, arg_customizer=DetectionListCommandFactory._load_bulk_generate_template_description, ) diff --git a/src/code42cli/cmds/detectionlists/enums.py b/src/code42cli/cmds/detectionlists/enums.py index 92a8f6c82..f5d69a553 100644 --- a/src/code42cli/cmds/detectionlists/enums.py +++ b/src/code42cli/cmds/detectionlists/enums.py @@ -1,6 +1,6 @@ class DetectionLists(object): DEPARTING_EMPLOYEE = u"departing-employee" - HIGH_RISK = u"high-risk" + HIGH_RISK_EMPLOYEE = u"high-risk-employee" class BulkCommandType(object): @@ -8,3 +8,10 @@ class BulkCommandType(object): def __iter__(self): return iter([self.ADD]) + + +class DetectionListUserKeys(object): + CLOUD_ALIAS = u"cloud_alias" + USERNAME = u"username" + NOTES = u"notes" + RISK_TAG = u"risk_tag" diff --git a/src/code42cli/cmds/detectionlists/high_risk.py b/src/code42cli/cmds/detectionlists/high_risk.py deleted file mode 100644 index d8c453861..000000000 --- a/src/code42cli/cmds/detectionlists/high_risk.py +++ /dev/null @@ -1,67 +0,0 @@ -from code42cli.cmds.detectionlists.enums import BulkCommandType, DetectionLists -from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory, create_usage_prefix -from code42cli.bulk import generate_template, create_bulk_processor - - -_NAME = DetectionLists.HIGH_RISK -_USAGE_PREFIX = create_usage_prefix(_NAME) - - -def load_subcommands(): - factory = DetectionListCommandFactory(_NAME) - bulk = factory.create_bulk_command(lambda: load_bulk_subcommands(factory)) - add = factory.create_add_command(add_high_risk_employee, _load_add_description) - return [bulk, add] - - -def load_bulk_subcommands(factory): - generate_template_cmd = factory.create_bulk_generate_template_command(generate_csv_file) - add = factory.create_bulk_add_command(bulk_add_high_risk_employees) - return [generate_template_cmd, add] - - -def generate_csv_file(cmd, path=None): - """Generates a csv template a user would need to fill-in for bulk adding users to the high - risk detection list.""" - handler = None - if cmd == BulkCommandType.ADD: - handler = add_high_risk_employee - generate_template(handler, path) - - -def bulk_add_high_risk_employees(sdk, profile, csv_file): - """Takes a csv file in the form `username,cloud_aliases,risk_factors,notes` with each row - representing an employee and adds each employee to the high risk detection list in a bulk - fashion. - - Args: - sdk (py42.sdk.SDKClient): The py42 sdk. - profile (Code42Profile): The profile under which to execute this command. - csv_file (str): The path to the csv file containing rows of users. - """ - processor = create_bulk_processor( - csv_file, lambda **kwargs: add_high_risk_employee(sdk, profile, **kwargs) - ) - processor.run() - - -def add_high_risk_employee( - sdk, profile, username, cloud_aliases=None, risk_factors=None, notes=None -): - """Adds the user with the given username to the high risk detection list. - - Args: - sdk (py42.sdk.SDKClient): The py42 sdk. - profile (Code42Profile): The profile under which to execute this command. - username (str): The username for the user. - cloud_aliases (iter[str]): A list of cloud aliases associated with the user. - risk_factors (iter[str]): The list of risk factors associated with the user. - notes (str): Notes about the user. - """ - - -def _load_add_description(argument_collection): - username = argument_collection.arg_configs[u"username"] - risk_factors = argument_collection.arg_configs[u"risk_factors"] - username.set_help(u"A user profile ID for detection lists.") - risk_factors.set_help(u"Risk factors associated with the employee.") diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py new file mode 100644 index 000000000..3f5797505 --- /dev/null +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -0,0 +1,57 @@ +from code42cli.cmds.detectionlists import ( + DetectionList, + DetectionListHandlers, + load_user_descriptions, + get_user_id, + update_user, +) +from code42cli.cmds.detectionlists.enums import DetectionListUserKeys + + +def load_subcommands(): + handlers = _get_handlers() + detection_list = DetectionList.create_high_risk_list(handlers) + return detection_list.load_subcommands() + + +def _get_handlers(): + return DetectionListHandlers(add=add_high_risk_employee, load_add=_load_add_description) + + +def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=None, notes=None): + """Adds an employee to the high risk detection list. + + Args: + sdk (py42.sdk.SDKClient): py42 + profile (C42Profile): Your code42 profile + username (str): The username of the employee to add. + cloud_alias (iter[str]): Alternative emails addresses for other cloud services. + risk_tag (iter[str]): Risk tags associated with the employee. + notes: (str): Notes about the employee. + """ + if risk_tag and type(risk_tag) != list: + risk_tag = risk_tag.split() + + if cloud_alias and type(cloud_alias) != list: + cloud_alias = cloud_alias.split() + + user_id = get_user_id(sdk, username) + update_user(sdk, user_id, cloud_alias, risk_tag, notes) + sdk.detectionlists.high_risk_employee.add(user_id) + + +def _load_add_description(argument_collection): + load_user_descriptions(argument_collection) + risk_tag = argument_collection.arg_configs[DetectionListUserKeys.RISK_TAG] + risk_tag.as_multi_val_param() + risk_tag.set_help( + u"Risk tags associated with the employee. " + u"Options include " + u"[HIGH_IMPACT_EMPLOYEE, " + u"ELEVATED_ACCESS_PRIVILEGES, " + u"PERFORMANCE_CONCERNS, " + u"FLIGHT_RISK, " + u"SUSPICIOUS_SYSTEM_ACTIVITY, " + u"POOR_SECURITY_PRACTICES, " + u"CONTRACT_EMPLOYEE]" + ) diff --git a/src/code42cli/cmds/detectionlists/main.py b/src/code42cli/cmds/detectionlists/main.py deleted file mode 100644 index 627ef9018..000000000 --- a/src/code42cli/cmds/detectionlists/main.py +++ /dev/null @@ -1,12 +0,0 @@ -import code42cli.cmds.detectionlists.high_risk as high_risk -from code42cli.commands import Command - - -def load_subcommands(): - return [ - Command( - u"high-risk", - u"Add or remove users from the `high risk` detection list.", - subcommand_loader=high_risk.load_subcommands, - ) - ] diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index e9d52ccf9..cd56c8e39 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -97,7 +97,7 @@ def _load_send_to_args(arg_collection): def _load_search_args(arg_collection): search_args = { enums.SearchArguments.ADVANCED_QUERY: ArgConfig( - u"--advanced-query", + u"--{}".format(enums.SearchArguments.ADVANCED_QUERY.replace(u"_", u"-")), help=u"A raw JSON file event query. " u"Useful for when the provided query parameters do not satisfy your requirements." u"WARNING: Using advanced queries ignores all other query parameters.", @@ -122,7 +122,7 @@ def _load_search_args(arg_collection): u"Available choices={0}".format(list(enums.ExposureType())), ), enums.SearchArguments.C42_USERNAME: ArgConfig( - u"--{}".format(enums.SearchArguments.C42_USERNAME), + u"--{}".format(enums.SearchArguments.C42_USERNAME.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to endpoint events for these users.", ), @@ -148,23 +148,23 @@ def _load_search_args(arg_collection): help=u"Limits events to only those from one of these sources. Example=Gmail.", ), enums.SearchArguments.FILE_NAME: ArgConfig( - u"--{}".format(enums.SearchArguments.FILE_NAME), + u"--{}".format(enums.SearchArguments.FILE_NAME.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to file events where the file has one of these names.", ), enums.SearchArguments.FILE_PATH: ArgConfig( - u"--{}".format(enums.SearchArguments.FILE_PATH), + u"--{}".format(enums.SearchArguments.FILE_PATH.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to file events where the file is located at one of these paths.", ), enums.SearchArguments.PROCESS_OWNER: ArgConfig( - u"--{}".format(enums.SearchArguments.PROCESS_OWNER), + u"--{}".format(enums.SearchArguments.PROCESS_OWNER.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to exposure events where one of these users " u"owns the process behind the exposure.", ), enums.SearchArguments.TAB_URL: ArgConfig( - u"--{}".format(enums.SearchArguments.TAB_URL), + u"--{}".format(enums.SearchArguments.TAB_URL.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to be exposure events with one of these destination tab URLs.", ), diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py index e8320add5..2affdfb8a 100644 --- a/src/code42cli/commands.py +++ b/src/code42cli/commands.py @@ -102,7 +102,8 @@ def get_arg_configs(self): def _get_arg_kvps(parsed_args, handler): # transform parsed args from `argparse` into a dict - kvps = dict(vars(parsed_args)) + parsed_args = vars(parsed_args) + kvps = {key.replace(u"-", u"_"): val for (key, val) in parsed_args.items()} kvps.pop(u"func", None) return _inject_params(kvps, handler) diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py index 9635813f3..95b9fbb28 100644 --- a/src/code42cli/invoker.py +++ b/src/code42cli/invoker.py @@ -9,8 +9,7 @@ class CommandInvoker(object): def __init__(self, top_command, cmd_parser=None): self._top_command = top_command self._cmd_parser = cmd_parser or CommandParser() - self._commands = {} - self._commands[u""] = self._top_command + self._commands = {u"": self._top_command} def run(self, input_args): """Locates a command that matches the one specified by @@ -21,7 +20,7 @@ def run(self, input_args): supplied by the user to `code42` cli command. """ path_parts = self._get_path_parts(input_args) - command = self._commands.get(" ".join(path_parts)) + command = self._commands.get(u" ".join(path_parts)) self._try_run_command(command, path_parts, input_args) def _get_path_parts(self, input_args): diff --git a/src/code42cli/main.py b/src/code42cli/main.py index d13838121..04ff3aef1 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,11 +1,11 @@ import platform import sys -from py42.sdk.settings import set_user_agent_suffix +from py42.settings import set_user_agent_suffix from code42cli import PRODUCT_NAME from code42cli.cmds import profile -from code42cli.cmds.detectionlists import main as dlmain +from code42cli.cmds.detectionlists import high_risk_employee as hre from code42cli.cmds.securitydata import main as secmain from code42cli.commands import Command from code42cli.invoker import CommandInvoker @@ -34,6 +34,8 @@ def main(): def _load_top_commands(): + detection_lists_description = u"For adding and removing employees from the {} detection list." + return [ Command( u"profile", u"For managing Code42 settings.", subcommand_loader=profile.load_subcommands @@ -44,12 +46,12 @@ def _load_top_commands(): subcommand_loader=secmain.load_subcommands, ), Command( - u"detection-lists", - u"For adding and removing employees from detection lists.", - subcommand_loader=dlmain.load_subcommands, + u"high-risk-employee", + detection_lists_description.format(u"high risk employee"), + subcommand_loader=hre.load_subcommands, ), ] -if __name__ == "__main__": +if __name__ == u"__main__": main() diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index d54b30ea3..a4693b65f 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -1,12 +1,12 @@ import py42.sdk -import py42.sdk.settings.debug as debug +import py42.settings.debug as debug from code42cli.util import print_error def create_sdk(profile, is_debug_mode): if is_debug_mode: - py42.sdk.settings.debug.level = debug.DEBUG + py42.settings.debug.level = debug.DEBUG try: password = profile.get_password() return py42.sdk.from_local_account(profile.authority_url, profile.username, password) diff --git a/tests/cmds/detectionlists/conftest.py b/tests/cmds/detectionlists/conftest.py new file mode 100644 index 000000000..cad9897d3 --- /dev/null +++ b/tests/cmds/detectionlists/conftest.py @@ -0,0 +1,16 @@ +import pytest + + +TEST_ID = "TEST_ID" + + +@pytest.fixture +def sdk_with_user(sdk): + sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_ID}]} + return sdk + + +@pytest.fixture +def sdk_without_user(sdk): + sdk.users.get_by_username.return_value = {"users": []} + return sdk diff --git a/tests/cmds/detectionlists/test_high_risk.py b/tests/cmds/detectionlists/test_high_risk.py deleted file mode 100644 index 525f27370..000000000 --- a/tests/cmds/detectionlists/test_high_risk.py +++ /dev/null @@ -1,38 +0,0 @@ -import pytest - -from code42cli import PRODUCT_NAME -from code42cli.cmds.detectionlists.high_risk import ( - generate_csv_file, - add_high_risk_employee, - bulk_add_high_risk_employees, -) - - -@pytest.fixture -def bulk_template_generator(mocker): - return mocker.patch("{}.cmds.detectionlists.high_risk.generate_template".format(PRODUCT_NAME)) - - -@pytest.fixture -def bulk_processor_factory(mocker, bulk_processor): - factory = mocker.patch( - "{}.cmds.detectionlists.high_risk.create_bulk_processor".format(PRODUCT_NAME) - ) - factory.return_value = bulk_processor - return factory - - -def test_generate_csv_file_generates_template(bulk_template_generator): - path = "some/path" - generate_csv_file("add", path) - bulk_template_generator.assert_called_once_with(add_high_risk_employee, path) - - -def test_bulk_add_high_risk_employees_runs(sdk, profile, bulk_processor, bulk_processor_factory): - bulk_add_high_risk_employees(sdk, profile, "") - assert bulk_processor.run.call_count == 1 - - -def test_bulk_add_high_risk_employees_creates_processor(sdk, profile, bulk_processor_factory): - bulk_add_high_risk_employees(sdk, profile, "csv_test") - assert bulk_processor_factory.call_args[0][0] == "csv_test" diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py new file mode 100644 index 000000000..ffde95280 --- /dev/null +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -0,0 +1,63 @@ +import pytest + +from code42cli.cmds.detectionlists.high_risk_employee import add_high_risk_employee +from .conftest import TEST_ID + + +def test_add_high_risk_employee_when_given_cloud_aliases_adds_alias(sdk_with_user, profile): + alias = "risk employee alias" + add_high_risk_employee(sdk_with_user, profile, "risky employee", cloud_alias=[alias]) + sdk_with_user.detectionlists.add_user_cloud_aliases.assert_called_once_with(TEST_ID, [alias]) + + +def test_add_high_risk_employee_when_given_str_of_cloud_aliases_adds_aliases( + sdk_with_user, profile +): + add_high_risk_employee( + sdk_with_user, + profile, + "risky employee", + cloud_alias="1@example.com 2@example.com 3@example.com", + ) + sdk_with_user.detectionlists.add_user_cloud_aliases.assert_called_once_with( + TEST_ID, ["1@example.com", "2@example.com", "3@example.com"] + ) + + +def test_add_high_risk_employee_when_given_risk_tags_adds_tags(sdk_with_user, profile): + add_high_risk_employee(sdk_with_user, profile, "risky employee", risk_tag="RF1 RF2 RF3") + sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( + TEST_ID, ["RF1", "RF2", "RF3"] + ) + + +def test_add_high_risk_employee_when_given_str_of_risk_tags_adds_tags(sdk_with_user, profile): + risk_tag = "BeingRisky" + add_high_risk_employee(sdk_with_user, profile, "risky employee", risk_tag=[risk_tag]) + sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with(TEST_ID, [risk_tag]) + + +def test_add_high_risk_employee_when_given_notes_updates_notes(sdk_with_user, profile): + notes = "being risky" + add_high_risk_employee(sdk_with_user, profile, "risky employee", notes=notes) + sdk_with_user.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) + + +def test_add_high_risk_employee_adds(sdk_with_user, profile): + add_high_risk_employee(sdk_with_user, profile, "risky employee") + sdk_with_user.detectionlists.high_risk_employee.add.assert_called_once_with(TEST_ID) + + +def test_add_high_risk_employee_when_user_does_not_exist_exits(sdk_without_user, profile): + with pytest.raises(SystemExit): + add_high_risk_employee(sdk_without_user, profile, "risky employee") + + +def test_add_high_risk_employee_when_user_does_not_exist_print_error( + sdk_without_user, profile, capsys +): + try: + add_high_risk_employee(sdk_without_user, profile, "risky employee") + except SystemExit: + capture = capsys.readouterr() + assert "ERROR: User 'risky employee' does not exist." in capture.out diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py new file mode 100644 index 000000000..807fe26ff --- /dev/null +++ b/tests/cmds/detectionlists/test_init.py @@ -0,0 +1,78 @@ +import pytest + +from code42cli import PRODUCT_NAME +from code42cli.cmds.detectionlists import ( + DetectionList, + DetectionListHandlers, + get_user_id, + update_user, +) +from .conftest import TEST_ID + + +_NAMESPACE = "{}.cmds.detectionlists".format(PRODUCT_NAME) + + +@pytest.fixture +def bulk_template_generator(mocker): + return mocker.patch("{}.generate_template".format(_NAMESPACE)) + + +@pytest.fixture +def bulk_processor(mocker): + return mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + + +def test_get_user_id_when_user_does_not_exist_exits(sdk_without_user): + with pytest.raises(SystemExit): + get_user_id(sdk_without_user, "risky employee") + + +def test_get_user_id_when_user_does_not_exist_print_error(sdk_without_user, capsys): + try: + get_user_id(sdk_without_user, "risky employee") + except SystemExit: + capture = capsys.readouterr() + assert "ERROR: User 'risky employee' does not exist." in capture.out + + +def test_update_user_adds_cloud_aliases(sdk_with_user, profile): + update_user( + sdk_with_user, TEST_ID, cloud_alias=["1@example.com", "2@example.com", "3@example.com"] + ) + sdk_with_user.detectionlists.add_user_cloud_aliases.assert_called_once_with( + TEST_ID, ["1@example.com", "2@example.com", "3@example.com"] + ) + + +def test_update_user_adds_risk_tags(sdk_with_user, profile): + update_user(sdk_with_user, TEST_ID, risk_tag=["rf1", "rf2", "rf3"]) + sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( + TEST_ID, ["rf1", "rf2", "rf3"] + ) + + +def test_update_user_updates_notes(sdk_with_user, profile): + notes = "notes" + update_user(sdk_with_user, TEST_ID, notes=notes) + sdk_with_user.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) + + +class TestDetectionList(object): + def test_load_commands_loads_expected_commands(self): + detection_list = DetectionList("TestList", DetectionListHandlers()) + cmds = detection_list.load_subcommands() + assert cmds[0].name == "bulk" + assert cmds[1].name == "add" + + def test_generate_csv_file_generates_template(self, bulk_template_generator): + handlers = DetectionListHandlers() + detection_list = DetectionList("TestList", handlers) + path = "some/path" + detection_list.generate_csv_file("add", path) + bulk_template_generator.assert_called_once_with(handlers.add_employee, path) + + def test_bulk_add_employees_uses_csv_path(self, sdk, profile, bulk_processor): + detection_list = DetectionList("TestList", DetectionListHandlers()) + detection_list.bulk_add_employees(sdk, profile, "csv_test") + assert bulk_processor.call_args[0][0] == "csv_test" diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index ab47c64ac..d380c097d 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -170,7 +170,7 @@ def test_delete_profile_warns_if_deleting_default( profilecmd.delete_profile("mockdefault") capture = capsys.readouterr() assert "mockdefault is currently the default profile!" in capture.out - + def test_delete_all_warns_if_profiles_exist(capsys, user_agreement, mock_cliprofile_namespace): mock_cliprofile_namespace.get_all_profiles.return_value = [ @@ -182,22 +182,22 @@ def test_delete_all_warns_if_profiles_exist(capsys, user_agreement, mock_cliprof assert "Are you sure you want to delete the following profiles?" in capture.out assert "test1" in capture.out assert "test2" in capture.out - + def test_delete_profile_does_nothing_if_user_doesnt_agree( user_disagreement, mock_cliprofile_namespace ): profilecmd.delete_profile("mockprofile") - assert mock_cliprofile_namespace.delete_profile.call_count == 0 - - + assert mock_cliprofile_namespace.delete_profile.call_count == 0 + + def test_delete_all_profiles_does_nothing_if_user_doesnt_agree( user_disagreement, mock_cliprofile_namespace ): profilecmd.delete_all_profiles() assert mock_cliprofile_namespace.delete_profile.call_count == 0 - - + + def test_delete_all_deletes_all_existing_profiles(user_agreement, mock_cliprofile_namespace): mock_cliprofile_namespace.get_all_profiles.return_value = [ create_mock_profile("test1"), @@ -206,8 +206,8 @@ def test_delete_all_deletes_all_existing_profiles(user_agreement, mock_cliprofil profilecmd.delete_all_profiles() mock_cliprofile_namespace.delete_profile.assert_any_call("test1") mock_cliprofile_namespace.delete_profile.assert_any_call("test2") - - + + def test_prompt_for_password_reset_if_credentials_valid_password_saved( mocker, user_agreement, mock_verify, mock_cliprofile_namespace ): diff --git a/tests/conftest.py b/tests/conftest.py index 9d9f28a87..77c1128eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -100,8 +100,3 @@ def func_with_sdk(sdk, one, two, three=None, four=None): def func_with_args(args): pass - - -@pytest.fixture -def bulk_processor(mocker): - return mocker.MagicMock(spec=BulkProcessor) diff --git a/tests/test_args.py b/tests/test_args.py index 77f398c4e..5a81cfe0a 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -50,6 +50,17 @@ def test_add_short_option_name_modifies_options_list(self): arg_config.add_short_option_name("-x") assert "-x" in arg_config.settings["options_list"] + def test_as_multi_val_param_modifies_nargs(self): + nargs = "*" + arg_config = ArgConfig("-t", "--test") + arg_config.as_multi_val_param(nargs) + assert arg_config.settings["nargs"] == "*" + + def test_as_multi_val_params_when_default_modifies_nargs_to_be_plus(self): + arg_config = ArgConfig("-t", "--test") + arg_config.as_multi_val_param() + assert arg_config.settings["nargs"] == "+" + class TestArgConfigCollection(object): def test_add_adds_arg_config(self): diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 9b1487003..4168c9d67 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -2,20 +2,36 @@ from io import IOBase from code42cli import PRODUCT_NAME -from code42cli.bulk import generate_template, BulkProcessor +from code42cli.bulk import generate_template, BulkProcessor, run_bulk_process + + +_NAMESPACE = "{}.bulk".format(PRODUCT_NAME) @pytest.fixture def mock_open(mocker): - mock = mocker.patch("{}.bulk.open".format(PRODUCT_NAME)) + mock = mocker.patch("{}.open".format(_NAMESPACE)) mock.return_value = mocker.MagicMock(spec=IOBase) return mock -def test_generate_template_uses_expected_path_and_column_names(mocker, mock_open): - def func_for_bulk(sdk, profile, test1, test2): - pass +@pytest.fixture +def bulk_processor(mocker): + return mocker.MagicMock(spec=BulkProcessor) + + +@pytest.fixture +def bulk_processor_factory(mocker, bulk_processor): + mock_factory = mocker.patch("{}._create_bulk_processor".format(_NAMESPACE)) + mock_factory.return_value = bulk_processor + return mock_factory + +def func_for_bulk(sdk, profile, test1, test2): + pass + + +def test_generate_template_uses_expected_path_and_column_names(mocker, mock_open): file_path = "some/path" template_file = mock_open.return_value.__enter__.return_value @@ -29,6 +45,16 @@ def test_generate_template_when_given_non_callable_handler_does_not_create(mock_ assert not mock_open.call_count +def test_run_bulk_process_calls_run(bulk_processor, bulk_processor_factory): + run_bulk_process("some/path", func_for_bulk) + assert bulk_processor.run.call_count + + +def test_run_bulk_process_creates_processor(bulk_processor_factory): + run_bulk_process("some/path", func_for_bulk) + bulk_processor_factory.assert_called_once_with("some/path", func_for_bulk) + + class TestBulkProcessor(object): def test_run_processes_rows(self, mocker, mock_open): processed_rows = [] @@ -36,7 +62,7 @@ def test_run_processes_rows(self, mocker, mock_open): def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - dict_reader = mocker.patch("{}.bulk._create_dict_reader".format(PRODUCT_NAME)) + dict_reader = mocker.patch("{}._create_dict_reader".format(_NAMESPACE)) dict_reader.return_value = [ {"test1": 1, "test2": 2}, {"test1": 3, "test2": 4}, diff --git a/tests/test_commands.py b/tests/test_commands.py index 182341426..bf9459664 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -161,18 +161,23 @@ def test_handler(one, two, three=None, four=None): def test_call_when_handler_with_sdk_passes_expected_values( self, mocker, mock_sdk_client, mock_profile_reader ): - def test_handler(sdk, one, two, three=None, four=None): + def test_handler(sdk, one, two, three=None, four_underscore=None): if ( sdk == mock_sdk_client and one == "testone" and two == "testtwo" and three == "testthree" - and four == "testfour" + and four_underscore == "testfour" ): return "success" command = Command("test", "test desc", "test usage", test_handler) - kvps = {"one": "testone", "two": "testtwo", "three": "testthree", "four": "testfour"} + kvps = { + "one": "testone", + "two": "testtwo", + "three": "testthree", + "four-underscore": "testfour", + } kvps = DictObject(kvps) assert command(kvps) == "success" diff --git a/tests/test_config.py b/tests/test_config.py index 7e956275e..20e06e619 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -69,9 +69,7 @@ def create_mock_profile_object(profile_name, authority_url=None, username=None): def create_internal_object(is_complete, default_profile_name=None): default_profile_name = default_profile_name or ConfigAccessor.DEFAULT_VALUE - internal_dict = { - ConfigAccessor.DEFAULT_PROFILE: default_profile_name, - } + internal_dict = {ConfigAccessor.DEFAULT_PROFILE: default_profile_name} internal_section = MockSection(_INTERNAL, internal_dict) def getboolean(*args): diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 7209aeaba..9fb7c222d 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,5 +1,5 @@ import py42.sdk -import py42.sdk.settings.debug as debug +import py42.settings.debug as debug import pytest from code42cli.sdk_client import create_sdk, validate_connection @@ -39,7 +39,7 @@ def mock_get_password(): profile.get_password = mock_get_password create_sdk(profile, True) - assert py42.sdk.settings.debug.level == debug.DEBUG + assert py42.settings.debug.level == debug.DEBUG def test_validate_connection_when_creating_sdk_raises_returns_false(error_sdk_factory): From 3f138cc8600de8b747830202325ea561c6c7fc49 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 17 Apr 2020 14:44:35 -0500 Subject: [PATCH 033/349] Feature/magic time args (#37) * implement magic strings * updated changelog * put back u-prefixes * missed u * more u's * more u's * add magic string description to readme * - refactor duplicate logic in _parse_{min,max}_timestamp into _parse_timestamp - fix tests to use new DateArgumentException --- CHANGELOG.md | 1 + README.md | 23 +++- .../cmds/securitydata/date_helper.py | 122 +++++++++++------- src/code42cli/cmds/securitydata/extraction.py | 6 +- src/code42cli/cmds/securitydata/main.py | 8 +- tests/cmds/securitydata/conftest.py | 8 +- tests/cmds/securitydata/test_date_helper.py | 87 +++++++++---- 7 files changed, 170 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b61f88c..b37fb562c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `--filename` flag renamed to `--file-name`. - `--filepath` flag renamed to `--file-path`. - `--processOwner` flag renamed to `--process-owner` +- `-b|--begin` and `-e|--end` arguments now accept shorthand date-range strings for days, hours, and minute intervals going back from the current time (e.g. `30d`, `24h`, `15m`). - Default profile validation logic added to prevent confusing error states. ### Added diff --git a/README.md b/README.md index 2c3c8df6c..ae39cc055 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,31 @@ Using the CLI, you can query for events and send them to three possible destinat To print events to stdout, do: ```bash -code42 security-data print -b 2020-02-02 +code42 security-data print -b ``` Note that `-b` or `--begin` is usually required. -To specify a time, do: + +And end date can also be given with `-e` or `--end` to query for a specific date range (if end is not passed, it will get all events up to the present time). + +To specify a begin/end time, you can pass a date or a date w/ time as a string: + +```bash +code42 security-data print -b '2020-02-02 12:51:00' +``` + +```bash +code42 security-data print -b 2020-02-02 +``` + +or a shorthand string specifying either days, hours, or minutes back from the current time: + +```bash +code42 security-data print -b 30d +``` ```bash -code42 security-data print -b 2020-02-02 12:51 +code42 security-data print -b 10d -e 12h ``` Begin date will be ignored if provided on subsequent queries using `-i`. diff --git a/src/code42cli/cmds/securitydata/date_helper.py b/src/code42cli/cmds/securitydata/date_helper.py index c3dbfb6bb..290e37199 100644 --- a/src/code42cli/cmds/securitydata/date_helper.py +++ b/src/code42cli/cmds/securitydata/date_helper.py @@ -1,10 +1,20 @@ +import re from datetime import datetime, timedelta from c42eventextractor.common import convert_datetime_to_timestamp from py42.sdk.queries.fileevents.filters.event_filter import EventTimestamp _MAX_LOOK_BACK_DAYS = 90 -_FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format." +_FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format, or a short value in days, hours, or minutes (e.g. 30d, 24h, 15m)" + + +class DateArgumentException(Exception): + def __init__(self, message=_FORMAT_VALUE_ERROR_MESSAGE): + super(DateArgumentException, self).__init__(message) + + +TIMESTAMP_REGEX = re.compile(u"(\d{4}-\d{2}-\d{2})\s*(.*)?") +MAGIC_TIME_REGEX = re.compile(u"(\d+)([dhm])$") def create_event_timestamp_filter(begin_date=None, end_date=None): @@ -14,34 +24,20 @@ def create_event_timestamp_filter(begin_date=None, end_date=None): begin_date: The begin date for the range. end_date: The end date for the range. """ - if begin_date and end_date: min_timestamp = _parse_min_timestamp(begin_date) max_timestamp = _parse_max_timestamp(end_date) return _create_in_range_filter(min_timestamp, max_timestamp) + elif begin_date and not end_date: min_timestamp = _parse_min_timestamp(begin_date) return _create_on_or_after_filter(min_timestamp) + elif end_date and not begin_date: max_timestamp = _parse_max_timestamp(end_date) return _create_on_or_before_filter(max_timestamp) -def _parse_max_timestamp(end_date): - if len(end_date) == 1: - end_date = _get_end_date_with_eod_time_if_needed(end_date) - max_time = _parse_timestamp(end_date) - max_time = _add_milliseconds(max_time) - else: - max_time = _parse_timestamp(end_date) - - return convert_datetime_to_timestamp(max_time) - - -def _add_milliseconds(max_time): - return max_time + timedelta(milliseconds=999) - - def _create_in_range_filter(min_timestamp, max_timestamp): _verify_timestamp_order(min_timestamp, max_timestamp) return EventTimestamp.in_range(min_timestamp, max_timestamp) @@ -55,45 +51,79 @@ def _create_on_or_before_filter(max_timestamp): return EventTimestamp.on_or_before(max_timestamp) -def _get_end_date_with_eod_time_if_needed(end_date): - return end_date[0], "23:59:59" +def _parse_timestamp(date_str, rounding_func): + timestamp_match = TIMESTAMP_REGEX.match(date_str) + magic_match = MAGIC_TIME_REGEX.match(date_str) + + if timestamp_match: + date, time = timestamp_match.groups() + dt = _get_dt_from_date_time_pair(date, time) + if not time: + dt = rounding_func(dt) + + elif magic_match: + num, period = magic_match.groups() + dt = _get_dt_from_magic_time_pair(num, period) + if period == u"d": + dt = rounding_func(dt) + + else: + raise DateArgumentException() + return dt def _parse_min_timestamp(begin_date_str): - min_time = _parse_timestamp(begin_date_str) - min_timestamp = convert_datetime_to_timestamp(min_time) - boundary_date = datetime.utcnow() - timedelta(days=_MAX_LOOK_BACK_DAYS) - boundary = convert_datetime_to_timestamp(boundary_date) - if min_timestamp and min_timestamp < boundary: - raise ValueError(u"'Begin date' must be within 90 days.") - return min_timestamp + dt = _parse_timestamp(begin_date_str, _round_datetime_to_day_start) + + boundary_date = _round_datetime_to_day_start( + datetime.utcnow() - timedelta(days=_MAX_LOOK_BACK_DAYS) + ) + if dt < boundary_date: + raise DateArgumentException(u"'Begin date' must be within 90 days.") + + return convert_datetime_to_timestamp(dt) + + +def _parse_max_timestamp(end_date_str): + dt = _parse_timestamp(end_date_str, _round_datetime_to_day_end) + return convert_datetime_to_timestamp(dt) + + +def _get_dt_from_date_time_pair(date, time): + date_format = u"%Y-%m-%d %H:%M:%S" + time = time or u"00:00:00" + date_string = u"{} {}".format(date, time) + try: + dt = datetime.strptime(date_string, date_format) + except ValueError: + raise DateArgumentException() + else: + return dt + + +def _get_dt_from_magic_time_pair(num, period): + num = int(num) + if period == u"d": + dt = datetime.utcnow() - timedelta(days=num) + elif period == u"h": + dt = datetime.utcnow() - timedelta(hours=num) + elif period == u"m": + dt = datetime.utcnow() - timedelta(minutes=num) + else: + raise DateArgumentException(u"Couldn't parse magic time string: {}{}".format(num, period)) + return dt def _verify_timestamp_order(min_timestamp, max_timestamp): if min_timestamp is None or max_timestamp is None: return if min_timestamp >= max_timestamp: - raise ValueError(u"Begin date cannot be after end date") + raise DateArgumentException(u"Begin date cannot be after end date") -def _parse_timestamp(date_and_time): - try: - date_str = _join_date_and_time(date_and_time) - date_format = u"%Y-%m-%d" if len(date_and_time) == 1 else u"%Y-%m-%d %H:%M:%S" - time = datetime.strptime(date_str, date_format) - return time - except ValueError: - raise ValueError(_FORMAT_VALUE_ERROR_MESSAGE) +def _round_datetime_to_day_start(dt): + return dt.replace(hour=0, minute=0, second=0, microsecond=0) -def _join_date_and_time(date_and_time): - if not date_and_time: - return None - date_str = date_and_time[0] - if len(date_and_time) == 1: - return date_str - if len(date_and_time) == 2: - date_str = "{0} {1}".format(date_str, date_and_time[1]) - else: - raise ValueError(_FORMAT_VALUE_ERROR_MESSAGE) - return date_str +def _round_datetime_to_day_end(dt): + return dt.replace(hour=23, minute=59, second=59, microsecond=999000) diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index 7371860a9..f46269702 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -117,10 +117,10 @@ def _create_filters(args): def _get_event_timestamp_filter(begin_date, end_date): try: - begin_date = begin_date.strip().split() if begin_date else None - end_date = end_date.strip().split() if end_date else None + begin_date = begin_date.strip() if begin_date else None + end_date = end_date.strip() if end_date else None return date_helper.create_event_timestamp_filter(begin_date, end_date) - except ValueError as ex: + except date_helper.DateArgumentException as ex: print_error(str(ex)) exit(1) diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index cd56c8e39..9c41445b0 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -106,13 +106,17 @@ def _load_search_args(arg_collection): u"-b", u"--{}".format(enums.SearchArguments.BEGIN_DATE), help=u"The beginning of the date range in which to look for events, " - u"in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", + u"can be a date/time in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format " + u"or a short value representing days (30d), hours (24h) or minutes (15m) from current " + u"time.", ), enums.SearchArguments.END_DATE: ArgConfig( u"-e", u"--{}".format(enums.SearchArguments.END_DATE), help=u"The end of the date range in which to look for events, " - u"in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format.", + u"can be a date/time in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format " + u"or a short value representing days (30d), hours (24h) or minutes (15m) from current " + u"time.", ), enums.SearchArguments.EXPOSURE_TYPES: ArgConfig( u"-t", diff --git a/tests/cmds/securitydata/conftest.py b/tests/cmds/securitydata/conftest.py index 8f8aee439..977ecf3e5 100644 --- a/tests/cmds/securitydata/conftest.py +++ b/tests/cmds/securitydata/conftest.py @@ -34,10 +34,10 @@ def get_test_date_str(days_ago): begin_date_str_with_time = "{0} 3:12:33".format(begin_date_str) end_date_str = get_test_date_str(days_ago=10) end_date_str_with_time = "{0} 11:22:43".format(end_date_str) -begin_date_list = [get_test_date_str(days_ago=89)] -begin_date_list_with_time = [get_test_date_str(days_ago=89), "3:12:33"] -end_date_list = [get_test_date_str(days_ago=10)] -end_date_list_with_time = [get_test_date_str(days_ago=10), "11:22:43"] +begin_date_str = get_test_date_str(days_ago=89) +begin_date_with_time = [get_test_date_str(days_ago=89), "3:12:33"] +end_date_str = get_test_date_str(days_ago=10) +end_date_with_time = [get_test_date_str(days_ago=10), "11:22:43"] @pytest.fixture(autouse=True) diff --git a/tests/cmds/securitydata/test_date_helper.py b/tests/cmds/securitydata/test_date_helper.py index dd78ae235..997fa6e31 100644 --- a/tests/cmds/securitydata/test_date_helper.py +++ b/tests/cmds/securitydata/test_date_helper.py @@ -1,11 +1,14 @@ import pytest -from code42cli.cmds.securitydata.date_helper import create_event_timestamp_filter +from code42cli.cmds.securitydata.date_helper import ( + create_event_timestamp_filter, + DateArgumentException, +) from .conftest import ( - begin_date_list, - begin_date_list_with_time, - end_date_list, - end_date_list_with_time, + begin_date_str, + begin_date_with_time, + end_date_str, + end_date_with_time, get_filter_value_from_json, get_test_date_str, ) @@ -22,57 +25,87 @@ def test_create_event_timestamp_filter_when_given_nones_returns_none(): def test_create_event_timestamp_filter_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_list) + ts_range = create_event_timestamp_filter(begin_date_str) actual = get_filter_value_from_json(ts_range, filter_index=0) - expected = "{0}T00:00:00.000Z".format(begin_date_list[0]) + expected = "{0}T00:00:00.000Z".format(begin_date_str) assert actual == expected def test_create_event_timestamp_filter_when_given_begin_with_time_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_list_with_time) + time_str = u"{} {}".format(*begin_date_with_time) + ts_range = create_event_timestamp_filter(time_str) actual = get_filter_value_from_json(ts_range, filter_index=0) - expected = "{0}T0{1}.000Z".format(begin_date_list_with_time[0], begin_date_list_with_time[1]) + expected = "{0}T0{1}.000Z".format(*begin_date_with_time) assert actual == expected def test_create_event_timestamp_filter_when_given_end_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_list, end_date_list) + ts_range = create_event_timestamp_filter(begin_date_str, end_date_str) actual = get_filter_value_from_json(ts_range, filter_index=1) - expected = "{0}T23:59:59.999Z".format(end_date_list[0]) + expected = "{0}T23:59:59.999Z".format(end_date_str) assert actual == expected def test_create_event_timestamp_filter_when_given_end_with_time_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_list, end_date_list_with_time) + end_date_str = "{} {}".format(*end_date_with_time) + ts_range = create_event_timestamp_filter(begin_date_str, end_date_str) actual = get_filter_value_from_json(ts_range, filter_index=1) - expected = "{0}T{1}.000Z".format(end_date_list_with_time[0], end_date_list_with_time[1]) + expected = "{0}T{1}.000Z".format(*end_date_with_time) assert actual == expected def test_create_event_timestamp_filter_when_given_both_begin_and_end_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_list, end_date_list_with_time) + end_date = "{} {}".format(*end_date_with_time) + ts_range = create_event_timestamp_filter(begin_date_str, end_date) actual_begin = get_filter_value_from_json(ts_range, filter_index=0) actual_end = get_filter_value_from_json(ts_range, filter_index=1) - expected_begin = "{0}T00:00:00.000Z".format(begin_date_list[0]) - expected_end = "{0}T{1}.000Z".format(end_date_list_with_time[0], end_date_list_with_time[1]) + expected_begin = "{0}T00:00:00.000Z".format(begin_date_str) + expected_end = "{0}T{1}.000Z".format(*end_date_with_time) assert actual_begin == expected_begin assert actual_end == expected_end def test_create_event_timestamp_filter_when_begin_more_than_ninety_days_back_causes_value_error(): - begin_date_tuple = (get_test_date_str(days_ago=91),) - with pytest.raises(ValueError): - create_event_timestamp_filter(begin_date_tuple) + begin_date_str = get_test_date_str(days_ago=91) + with pytest.raises(DateArgumentException): + create_event_timestamp_filter(begin_date_str) def test_create_event_timestamp_filter_when_end_is_before_begin_causes_value_error(): - begin_date_tuple = (get_test_date_str(days_ago=5),) - end_date_str = (get_test_date_str(days_ago=7),) - with pytest.raises(ValueError): - create_event_timestamp_filter(begin_date_tuple, end_date_str) + begin_date = get_test_date_str(days_ago=5) + end_date = get_test_date_str(days_ago=7) + with pytest.raises(DateArgumentException): + create_event_timestamp_filter(begin_date, end_date) + + +def test_create_event_timestamp_filter_when_args_are_magic_days_builds_expected_query(): + begin_magic_str = "10d" + end_magic_str = "6d" + ts_range = create_event_timestamp_filter(begin_magic_str, end_magic_str) + actual_begin = get_filter_value_from_json(ts_range, filter_index=0) + expected_begin = "{}T00:00:00.000Z".format(get_test_date_str(days_ago=10)) + actual_end = get_filter_value_from_json(ts_range, filter_index=1) + expected_end = "{}T23:59:59.999Z".format(get_test_date_str(days_ago=6)) + assert actual_begin == expected_begin + assert actual_end == expected_end -def test_create_event_timestamp_filter_when_given_three_date_args_raises_value_error(): - begin_date_tuple = (get_test_date_str(days_ago=5), "12:00:00", "end_date=12:00:00") - with pytest.raises(ValueError): - create_event_timestamp_filter(begin_date_tuple) +def test_create_event_timestamp_filter_when_given_improperly_formatted_arg_raises_value_error(): + missing_seconds = "{} {}".format(get_test_date_str(days_ago=5), "12:00") + month_first_date = "01-01-2020" + time_typo = "{} {}".format(get_test_date_str(days_ago=5), "b20:30:00") + bad_magic = "2months" + bad_magic_2 = "100s" + bad_magic_3 = "10 d" + with pytest.raises(DateArgumentException): + create_event_timestamp_filter(missing_seconds) + with pytest.raises(DateArgumentException): + create_event_timestamp_filter(month_first_date) + with pytest.raises(DateArgumentException): + create_event_timestamp_filter(time_typo) + with pytest.raises(DateArgumentException): + create_event_timestamp_filter(bad_magic) + with pytest.raises(DateArgumentException): + create_event_timestamp_filter(bad_magic_2) + with pytest.raises(DateArgumentException): + create_event_timestamp_filter(bad_magic_3) From 51b3e19ca5ae7d35f3d7045dd661f2ee340d33cf Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 22 Apr 2020 14:32:44 +0000 Subject: [PATCH 034/349] Feature/rm hr (#36) --- CHANGELOG.md | 8 +- README.md | 40 ++++- setup.py | 4 +- src/code42cli/args.py | 9 +- src/code42cli/bulk.py | 119 +++++++++---- src/code42cli/cmds/detectionlists/__init__.py | 108 +++++++----- src/code42cli/cmds/detectionlists/commands.py | 36 +++- src/code42cli/cmds/detectionlists/enums.py | 3 +- .../cmds/detectionlists/high_risk_employee.py | 33 ++-- src/code42cli/cmds/profile.py | 6 +- src/code42cli/cmds/securitydata/extraction.py | 10 +- src/code42cli/commands.py | 15 +- src/code42cli/errors.py | 33 ++++ src/code42cli/invoker.py | 19 ++- src/code42cli/logger.py | 7 +- src/code42cli/worker.py | 38 +++++ .../detectionlists/test_high_risk_employee.py | 69 ++++---- tests/cmds/detectionlists/test_init.py | 51 ++++-- tests/cmds/securitydata/test_extraction.py | 29 ++-- tests/conftest.py | 1 - tests/test_bulk.py | 156 ++++++++++++++++-- tests/test_commands.py | 6 +- tests/test_invoker.py | 29 ++++ 23 files changed, 635 insertions(+), 194 deletions(-) create mode 100644 src/code42cli/errors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b37fb562c..db1abf521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `--c42username` flag renamed to `--c42-username`. - `--filename` flag renamed to `--file-name`. - `--filepath` flag renamed to `--file-path`. - - `--processOwner` flag renamed to `--process-owner` + - `--processOwner` flag renamed to `--process-owner`. - `-b|--begin` and `-e|--end` arguments now accept shorthand date-range strings for days, hours, and minute intervals going back from the current time (e.g. `30d`, `24h`, `15m`). - Default profile validation logic added to prevent confusing error states. @@ -31,9 +31,11 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `bulk` with subcommands: - `add`: that takes a csv file of users. - `generate-template`: that creates the csv file template. And parameters: - - `cmd`: with the option `add`. + - `cmd`: with options `add` and `remove`. - `path` - - `add` that takes parameters: `--username`, `--cloud-aliases`, `--risk-factors`, and `--notes`. + - `remove`: that takes a list of users in a file. + - `add` that takes parameters: `--username`, `--cloud-alias`, `--risk-factor`, and `--notes`. + - `remove` that takes a username. ### Removed diff --git a/README.md b/README.md index ae39cc055..8b77dff23 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # The Code42 CLI Use the `code42` command to interact with your Code42 environment. -`code42 security-data` is a CLI tool for extracting AED events. -Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a checkpoint -(provided you do not change your query). + +* `code42 security-data` is a CLI tool for extracting AED events. + Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a + checkpoint (provided you do not change your query). +* `code42 high-risk-employee` is a collection of tools for managing the high risk employee detection list. ## Requirements @@ -53,6 +55,8 @@ To see all your profiles, do: code42 profile list ``` +## Security Data + Using the CLI, you can query for events and send them to three possible destination types: * stdout * A file @@ -150,9 +154,37 @@ Each destination-type subcommand shares query parameters You cannot use other query parameters if you use `--advanced-query`. To learn more about acceptable arguments, add the `-h` flag to `code42` or any of the destination-type subcommands. +## Detection Lists + +You can both add and remove employees from detection lists using the CLI. This example uses `high-risk-employee`. + +```bash +code42 high-risk-employee add user@example.com --notes "These are notes" +code42 high-risk-employee remove user@example.com +``` + +Detection lists include a `bulk` command. To add employees to a list, you can pass in a csv file. First, generate the +csv file for the desired command by executing the `generate-template` command: + +```bash +code42 high-risk-employee bulk generate-template add +``` + +Notice that `generate-template` takes a `cmd` parameter for determining what type of template to generate. In the +example above, we give it the value `add` to generate a file for bulk adding users to the high risk employee list. + +Next, fill out the csv file with all the users and then pass it in as a parameter to `bulk add`: + +```bash +code42 high-risk-employee bulk add users_to_add.csv +``` + +Note that for `bulk remove`, the file only has to be an end-line delimited list of users with one line per user. + ## Known Issues -Only the first 10,000 of each set of events containing the exact same insertion timestamp is reported. +In `security-data`, only the first 10,000 of each set of events containing the exact same insertion timestamp is +reported. ## Troubleshooting diff --git a/setup.py b/setup.py index 82a91aeb1..8a61aed10 100644 --- a/setup.py +++ b/setup.py @@ -21,10 +21,10 @@ package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ - "c42eventextractor==0.2.5", + "c42eventextractor", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42==0.9.0", + "py42", ], license="MIT", include_package_data=True, diff --git a/src/code42cli/args.py b/src/code42cli/args.py index 82e1394a8..229d2371b 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -1,7 +1,10 @@ from collections import OrderedDict import inspect + PROFILE_HELP = u"The name of the Code42 profile use when executing this command." +SDK_ARG_NAME = u"sdk" +PROFILE_ARG_NAME = u"profile" class ArgConfig(object): @@ -61,14 +64,14 @@ def get_auto_arg_configs(handler): for arg_position, key in enumerate(argspec.args): # do not create cli parameters for arguments named "sdk", "args", or "kwargs" - if not key in [u"sdk", u"args", u"kwargs", u"self"]: + if not key in [SDK_ARG_NAME, u"args", u"kwargs", u"self"]: arg_config = _create_auto_args_config( arg_position, key, argspec, num_args, num_kw_args ) _set_smart_defaults(arg_config) arg_configs.append(key, arg_config) - if u"sdk" in argspec.args: + if SDK_ARG_NAME in argspec.args: _build_sdk_arg_configs(arg_configs) return arg_configs @@ -108,5 +111,5 @@ def _build_sdk_arg_configs(arg_config_collection): """Add extra cli parameters that will always be relevant when a handler needs the sdk.""" profile = ArgConfig(u"--profile", help=PROFILE_HELP) debug = ArgConfig(u"-d", u"--debug", action=u"store_true", help=u"Turn on Debug logging.") - extras = {u"profile": profile, u"debug": debug} + extras = {PROFILE_ARG_NAME: profile, u"debug": debug} arg_config_collection.extend(extras) diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 0e74adba5..4bbbe619c 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -4,60 +4,121 @@ from code42cli.compat import open, str from code42cli.worker import Worker +from code42cli.errors import print_errors_occurred +from code42cli.args import SDK_ARG_NAME, PROFILE_ARG_NAME def generate_template(handler, path=None): - """Looks at the parameter names of `handler` and creates a csv file with the same column names. + """Looks at the parameter names of `handler` and creates a file with the same column names. If + `handler` only has one parameter that is not `sdk` or `profile`, it will create a blank file. + This is useful for commands such as `remove` which only require a list of users. """ - if callable(handler): - argspec = inspect.getargspec(handler) - columns = [str(arg) for arg in argspec.args if arg not in [u"sdk", u"profile"]] - path = path or u"{0}/{1}.csv".format(os.getcwd(), str(handler.__name__)) - _write_template_file(path, columns) + path = path or u"{0}/{1}.csv".format(os.getcwd(), str(handler.__name__)) + args = [ + arg + for arg in inspect.getargspec(handler).args + if arg != SDK_ARG_NAME and arg != PROFILE_ARG_NAME + ] + if len(args) <= 1: + print( + u"A blank file was generated because there are no csv headers needed for this command. " + u"Simply enter one {} per line.".format(args[0]) + ) + # Set args to None so that we don't make a header out of the single arg. + args = None -def _write_template_file(path, columns): - with open(path, u"w", encoding=u"utf8") as new_csv: - new_csv.write(u",".join(columns)) + _write_template_file(path, args) -def run_bulk_process(csv_file_path, row_handler): - processor = _create_bulk_processor(csv_file_path, row_handler) +def _write_template_file(path, columns=None): + with open(path, u"w", encoding=u"utf8") as new_file: + if columns: + new_file.write(u",".join(columns)) + + +def run_bulk_process(file_path, row_handler, reader=None): + """Runs a bulk process. + + Args: + file_path (str or unicode): The path to the file feeding the data for the bulk process. + row_handler (callable): A callable that you define to process values from the row as + either *args or **kwargs. + reader: (CSVReader or FlatFileReader, optional): A generator that reads rows and yields data into + `row_handler`. If None, it will use a CSVReader. Defaults to None. + """ + reader = reader or CSVReader() + processor = _create_bulk_processor(file_path, row_handler, reader) processor.run() -def _create_bulk_processor(csv_file_path, row_handler): +def _create_bulk_processor(file_path, row_handler, reader): """A factory method to create the bulk processor, useful for testing purposes.""" - return BulkProcessor(csv_file_path, row_handler) + return BulkProcessor(file_path, row_handler, reader) class BulkProcessor(object): - """A class for bulk processing a csv file. + """A class for bulk processing a file. Args: - csv_file_path (str or unicode): The path to the csv file for processing. - row_handler (callable): To be executed on each row given **kwargs representing the column - names mapped to the properties found in the row. For example, if the csv file header - looked like `prop_a,prop_b` and the next row looked like `1,test`, then row handler - would receive args `prop_a: '1', prop_b: 'test'` when processing row 1. + file_path (str or unicode): The path to the file for processing. + row_handler (callable): A callable that you define to process values from the row as + either *args or **kwargs. For example, if it's a csv file with header `prop_a,prop_b` + and first row `1,test`, then `row_handler` should receive kwargs + `prop_a: '1', prop_b: 'test'` when processing the first row. If it's a flat file, then + `row_handler` only needs to take an extra arg. + reader (CSVReader or FlatFileReader): A generator that reads rows and yields data into `row_handler`. """ - def __init__(self, csv_file_path, row_handler): - self.csv_file_path = csv_file_path + def __init__(self, file_path, row_handler, reader): + self.file_path = file_path self._row_handler = row_handler + self._reader = reader self.__worker = Worker(5) def run(self): """Processes the csv file specified in the ctor, calling `self.row_handler` on each row.""" - with open(self.csv_file_path, newline=u"", encoding=u"utf8") as csv_file: - rows = _create_dict_reader(csv_file) - self._process_rows(rows) + with open(self.file_path, newline=u"", encoding=u"utf8") as bulk_file: + for row in self._reader(bulk_file=bulk_file): + self._process_row(row) self.__worker.wait() + self._print_result() + + def _process_row(self, row): + if type(row) is dict: + self._process_csv_row(row) + elif row: + self._process_flat_file_row(row.strip()) + + def _process_csv_row(self, row): + # Removes problems from including extra comments. Error messages from out of order args + # are more indicative this way too. + row.pop(None, None) + self.__worker.do_async(lambda *args, **kwargs: self._row_handler(*args, **kwargs), **row) + + def _process_flat_file_row(self, row): + if row: + self.__worker.do_async(lambda *args, **kwargs: self._row_handler(*args, **kwargs), row) + + def _print_result(self): + stats = self.__worker.stats + successes = stats.total - stats.total_errors + print(u"{} processed successfully out of {}.".format(successes, stats.total)) + if stats.total_errors: + print_errors_occurred() + + +class CSVReader(object): + """A generator that yields header keys mapped to row values from a csv file.""" + + def __call__(self, *args, **kwargs): + for row in csv.DictReader(kwargs.get(u"bulk_file")): + yield row - def _process_rows(self, rows): - for row in rows: - self.__worker.do_async(lambda **kwargs: self._row_handler(**kwargs), **row) +class FlatFileReader(object): + """A generator that yields a single-value per row from a file.""" -def _create_dict_reader(csv_file): - return csv.DictReader(csv_file) + def __call__(self, *args, **kwargs): + for row in kwargs[u"bulk_file"]: + yield row diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 6b6e240f7..ec7a1bfa5 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,11 +1,20 @@ from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory -from code42cli.bulk import generate_template, run_bulk_process +from code42cli.bulk import generate_template, run_bulk_process, CSVReader, FlatFileReader +from code42cli.util import print_error from code42cli.cmds.detectionlists.enums import ( BulkCommandType, DetectionLists, DetectionListUserKeys, ) -from code42cli.util import print_error + + +class UserDoesNotExistError(Exception): + """An error to represent a username that is not in our system. The CLI shows this error when + the user tries to add or remove a user that does not exist. This error is not shown during + bulk add or remove.""" + + def __init__(self, username): + super(UserDoesNotExistError, self).__init__(u"User '{}' does not exist.".format(username)) class DetectionListHandlers(object): @@ -23,20 +32,13 @@ def __init__(self, add=None, remove=None, load_add=None): self.load_add_description = load_add -class UserDoesNotExistError(Exception): - """An error to represent a username that is not in our system.""" - - def __init__(self, username): - super(UserDoesNotExistError, self).__init__(u"User '{}' does not exist.".format(username)) - - class DetectionList(object): """An object representing a Code42 detection list. Use this class by passing in handlers for adding and removing employees. This class will handle the bulk-related commands and some shared help texts. Args: - list_name (str): An option from the DetectionLists enum. For convenience, use one of the + list_name (str or unicode): An option from the DetectionLists enum. For convenience, use one of the given `classmethods`. handlers (DetectionListHandlers): A DTO containing implementations for adding / removing users from specific lists. @@ -49,15 +51,15 @@ def __init__(self, list_name, handlers, cmd_factory=None): self.factory = cmd_factory or DetectionListCommandFactory(list_name) @classmethod - def create_high_risk_list(cls, handlers): - """Creates a high risk detection list. + def create_high_risk_employee_list(cls, handlers): + """Creates a high risk employee detection list. Args: handlers (DetectionListHandlers): A DTO containing implementations for adding / removing users from specific lists. Returns: - DetectionList: A high-risk employee detection list. + DetectionList: A high risk employee detection list. """ return cls(DetectionLists.HIGH_RISK_EMPLOYEE, handlers) @@ -67,28 +69,35 @@ def load_subcommands(self): add = self.factory.create_add_command( self.handlers.add_employee, self.handlers.load_add_description ) - return [bulk, add] + remove = self.factory.create_remove_command( + self.handlers.remove_employee, _load_username_description + ) + return [bulk, add, remove] def _load_bulk_subcommands(self): generate_template_cmd = self.factory.create_bulk_generate_template_command( - self.generate_csv_file + self.generate_template_file ) add = self.factory.create_bulk_add_command(self.bulk_add_employees) - return [generate_template_cmd, add] + remove = self.factory.create_bulk_remove_command(self.bulk_remove_employees) + return [generate_template_cmd, add, remove] - def generate_csv_file(self, cmd, path=None): - """Generates a csv template a user would need to fill-in for bulk adding users to the + def generate_template_file(self, cmd, path=None): + """Generates a template file a user would need to fill-in for bulk operating on the detection list. Args: - cmd (str): An option from the `BulkCommandType` enum specifying which type of csv to + cmd (str or unicode): An option from the `BulkCommandType` enum specifying which type of file to generate. - path (str, optional): A path to put the file after it's generated. If None, will use + path (str or unicode, optional): A path to put the file after it's generated. If None, will use the current working directory. Defaults to None. """ handler = None if cmd == BulkCommandType.ADD: handler = self.handlers.add_employee + elif cmd == BulkCommandType.REMOVE: + handler = self.handlers.remove_employee + generate_template(handler, path) def bulk_add_employees(self, sdk, profile, csv_file): @@ -98,37 +107,51 @@ def bulk_add_employees(self, sdk, profile, csv_file): Args: sdk (py42.sdk.SDKClient): The py42 sdk. profile (Code42Profile): The profile under which to execute this command. - csv_file (str): The path to the csv file containing rows of users. + csv_file (str or unicode): The path to the csv file containing rows of users. """ - run_bulk_process(csv_file, lambda **kwargs: self._add_employee(sdk, profile, **kwargs)) + run_bulk_process( + csv_file, lambda **kwargs: self._add_employee(sdk, profile, **kwargs), CSVReader() + ) - def _add_employee(self, sdk, profile, **kwargs): - if ( - kwargs.has_key(DetectionListUserKeys.CLOUD_ALIAS) - and type(kwargs[DetectionListUserKeys.CLOUD_ALIAS]) != list - ): - kwargs[DetectionListUserKeys.CLOUD_ALIAS] = kwargs[ - DetectionListUserKeys.CLOUD_ALIAS - ].split() + def bulk_remove_employees(self, sdk, profile, users_file): + """Takes a flat file with each row containing a username and removes them all from the + detection list in a bulk fashion. + + Args: + sdk (py42.sdk.SDKClient): The py42 sdk. + profile (Code42Profile): The profile under which to execute this command. + users_file (str or unicode): The path to the file containing rows of user names. + """ + run_bulk_process( + users_file, + lambda *args, **kwargs: self._remove_employee(sdk, profile, *args, **kwargs), + FlatFileReader(), + ) + def _add_employee(self, sdk, profile, **kwargs): self.handlers.add_employee(sdk, profile, **kwargs) + def _remove_employee(self, sdk, profile, *args, **kwargs): + self.handlers.remove_employee(sdk, profile, *args, **kwargs) + + +def _load_username_description(argument_collection): + username = argument_collection.arg_configs[DetectionListUserKeys.USERNAME] + username.set_help(u"A code42 username for an employee.") + def load_user_descriptions(argument_collection): """Loads the arg descriptions related to updating fields about a detection list user, such as - notes or cloud aliases. + notes or a cloud alias. Args: argument_collection (ArgConfigCollection): The arg configs off the command that needs its user descriptions loaded. """ - username = argument_collection.arg_configs[DetectionListUserKeys.USERNAME] + _load_username_description(argument_collection) cloud_alias = argument_collection.arg_configs[DetectionListUserKeys.CLOUD_ALIAS] notes = argument_collection.arg_configs[DetectionListUserKeys.NOTES] - - username.set_help(u"The code42 username of the user you want to add.") cloud_alias.set_help(u"Alternative emails addresses for other cloud services.") - cloud_alias.as_multi_val_param() notes.set_help(u"Notes about the employee.") @@ -145,8 +168,9 @@ def get_user_id(sdk, username): """ users = sdk.users.get_by_username(username)[u"users"] if not users: - print_error(str(UserDoesNotExistError(username))) - exit(1) + ex = UserDoesNotExistError(username) + print_error(str(ex)) + raise ex return users[0][u"userUid"] @@ -154,14 +178,14 @@ def update_user(sdk, user_id, cloud_alias=None, risk_tag=None, notes=None): """Updates a detection list user. Args: - user_id (str): The ID of the user to update. This is their `userUid` found from + user_id (str or unicode): The ID of the user to update. This is their `userUid` found from `sdk.users.get_by_username()`. - cloud_alias (iter[str]): A list of cloud aliases to add to the user. - risk_tag (iter[str]): A list of risk tags associated with user. - notes (str): Notes about the user. + cloud_alias (str or unicode): A cloud alias to add to the user. + risk_tag (iter[str or unicode]): A list of risk tags associated with user. + notes (str or unicode): Notes about the user. """ if cloud_alias: - sdk.detectionlists.add_user_cloud_aliases(user_id, cloud_alias) + sdk.detectionlists.add_user_cloud_alias(user_id, cloud_alias) if risk_tag: sdk.detectionlists.add_user_risk_tags(user_id, risk_tag) if notes: diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py index 1f4bb6c72..dbd81a355 100644 --- a/src/code42cli/cmds/detectionlists/commands.py +++ b/src/code42cli/cmds/detectionlists/commands.py @@ -11,6 +11,8 @@ def create_bulk_usage_prefix(detection_list_name): class DetectionListCommandFactory: + _USAGE_SUFFIX = u" " + def __init__(self, detection_list_name): self._name = detection_list_name self._usage_prefix = create_usage_prefix(detection_list_name) @@ -25,9 +27,18 @@ def create_bulk_command(self, subcommand_loader): def create_add_command(self, handler, arg_customizer): return Command( - u"add", + BulkCommandType.ADD, u"Add a user to the {} detection list.".format(self._name), - u"{} add ".format(self._usage_prefix), + u"{} {} {}".format(self._usage_prefix, BulkCommandType.ADD, self._USAGE_SUFFIX), + handler=handler, + arg_customizer=arg_customizer, + ) + + def create_remove_command(self, handler, arg_customizer): + return Command( + BulkCommandType.REMOVE, + u"Remove a user from the {} detection list.".format(self._name), + u"{} {} {}".format(self._usage_prefix, BulkCommandType.REMOVE, self._USAGE_SUFFIX), handler=handler, arg_customizer=arg_customizer, ) @@ -43,13 +54,22 @@ def create_bulk_generate_template_command(self, handler): def create_bulk_add_command(self, handler): return Command( - u"add", + BulkCommandType.ADD, u"Bulk add users to the {} detection list using a csv file.".format(self._name), - u"{} add ".format(self._bulk_usage_prefix), + u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.ADD), handler=handler, arg_customizer=self._load_bulk_add_description, ) + def create_bulk_remove_command(self, handler): + return Command( + BulkCommandType.REMOVE, + u"Bulk remove users from the {} detection list using a file.".format(self._name), + u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.REMOVE), + handler=handler, + arg_customizer=self._load_bulk_remove_description, + ) + @staticmethod def _load_bulk_generate_template_description(argument_collection): cmd_type = argument_collection.arg_configs[u"cmd"] @@ -63,3 +83,11 @@ def _load_bulk_add_description(self, argument_collection): self._name ) ) + + def _load_bulk_remove_description(self, argument_collection): + users_file = argument_collection.arg_configs[u"users_file"] + users_file.set_help( + u"A file containing a line-separated list of users to remove form the {} detection list".format( + self._name + ) + ) diff --git a/src/code42cli/cmds/detectionlists/enums.py b/src/code42cli/cmds/detectionlists/enums.py index f5d69a553..aa0697cdf 100644 --- a/src/code42cli/cmds/detectionlists/enums.py +++ b/src/code42cli/cmds/detectionlists/enums.py @@ -5,9 +5,10 @@ class DetectionLists(object): class BulkCommandType(object): ADD = u"add" + REMOVE = u"remove" def __iter__(self): - return iter([self.ADD]) + return iter([self.ADD, self.REMOVE]) class DetectionListUserKeys(object): diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index 3f5797505..7bc73caeb 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -9,37 +9,48 @@ def load_subcommands(): - handlers = _get_handlers() - detection_list = DetectionList.create_high_risk_list(handlers) + handlers = _create_handlers() + detection_list = DetectionList.create_high_risk_employee_list(handlers) return detection_list.load_subcommands() -def _get_handlers(): - return DetectionListHandlers(add=add_high_risk_employee, load_add=_load_add_description) +def _create_handlers(): + return DetectionListHandlers( + add=add_high_risk_employee, remove=remove_high_risk_employee, load_add=_load_add_description + ) def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=None, notes=None): - """Adds an employee to the high risk detection list. + """Adds an employee to the high risk employee detection list. Args: - sdk (py42.sdk.SDKClient): py42 - profile (C42Profile): Your code42 profile + sdk (py42.sdk.SDKClient): py42. + profile (C42Profile): Your code42 profile. username (str): The username of the employee to add. - cloud_alias (iter[str]): Alternative emails addresses for other cloud services. + cloud_alias (str): An alternative email address for another cloud service. risk_tag (iter[str]): Risk tags associated with the employee. notes: (str): Notes about the employee. """ if risk_tag and type(risk_tag) != list: risk_tag = risk_tag.split() - if cloud_alias and type(cloud_alias) != list: - cloud_alias = cloud_alias.split() - user_id = get_user_id(sdk, username) update_user(sdk, user_id, cloud_alias, risk_tag, notes) sdk.detectionlists.high_risk_employee.add(user_id) +def remove_high_risk_employee(sdk, profile, username): + """Removes an employee from the high risk employee detection list. + + Args: + sdk (py42.sdk.SDKClient): py42. + profile (C42Profile): Your code42 profile. + username (str): The username of the employee to remove. + """ + user_id = get_user_id(sdk, username) + sdk.detectionlists.high_risk_employee.remove(user_id) + + def _load_add_description(argument_collection): load_user_descriptions(argument_collection) risk_tag = argument_collection.arg_configs[DetectionListUserKeys.RISK_TAG] diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index c0b724ec6..b0f6c5b06 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -3,7 +3,7 @@ from getpass import getpass import code42cli.profile as cliprofile -from code42cli.args import PROFILE_HELP +from code42cli.args import PROFILE_HELP, PROFILE_ARG_NAME from code42cli.commands import Command from code42cli.sdk_client import validate_connection from code42cli.util import does_user_agree, print_error, print_no_existing_profile_message @@ -98,7 +98,7 @@ def update_profile(name=None, server=None, username=None, disable_ssl_errors=Non cliprofile.update_profile(profile.name, server, username, disable_ssl_errors) _prompt_for_allow_password_set(profile.name) print(u"Profile '{}' has been updated.".format(profile.name)) - + def prompt_for_password_reset(name=None): """Securely prompts for your password and then stores it using keyring.""" @@ -164,7 +164,7 @@ def _load_optional_profile_description(argument_collection): def _load_profile_create_descriptions(argument_collection): - profile = argument_collection.arg_configs[u"profile"] + profile = argument_collection.arg_configs[PROFILE_ARG_NAME] profile.set_help(PROFILE_HELP) _load_profile_settings_descriptions(argument_collection) diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index f46269702..24bf5db4b 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -16,9 +16,9 @@ from code42cli.cmds.shared.cursor_store import FileEventCursorStore from code42cli.compat import str from code42cli.util import is_interactive, print_bold, print_error, print_to_stderr +import code42cli.errors as errors -_EXCEPTIONS_OCCURRED = False _TOTAL_EVENTS = 0 @@ -131,8 +131,7 @@ def _create_event_handlers(output_logger, cursor_store): def handle_error(exception): error_logger.error(exception) - global _EXCEPTIONS_OCCURRED - _EXCEPTIONS_OCCURRED = True + errors.ERRORED = True handlers.handle_error = handle_error @@ -171,8 +170,9 @@ def _verify_compatibility_with_advanced_query(key, val): def _handle_result(): - if is_interactive() and _EXCEPTIONS_OCCURRED: - print_error(u"View exceptions that occurred at [HOME]/.code42cli/log/code42_errors.") + # Have to call this explicitly (instead of relying on invoker) because errors are caught in + # `c42eventextractor`. + errors.print_errors_occurred_if_needed() if not _TOTAL_EVENTS: print_to_stderr(u"No results found\n") diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py index 2affdfb8a..1676fd9cd 100644 --- a/src/code42cli/commands.py +++ b/src/code42cli/commands.py @@ -1,7 +1,7 @@ import inspect from code42cli import profile as cliprofile -from code42cli.args import get_auto_arg_configs +from code42cli.args import get_auto_arg_configs, SDK_ARG_NAME, PROFILE_ARG_NAME from code42cli.sdk_client import create_sdk @@ -46,7 +46,6 @@ def __init__( subcommand_loader=None, use_single_arg_obj=None, ): - self._name = name self._description = description self._usage = usage @@ -121,15 +120,15 @@ def _inject_params(kvps, handler): dict: The dictionary of parsed command line arguments with possibly additional populated fields. """ - if _handler_has_arg(u"sdk", handler): - profile_name = kvps.pop(u"profile", None) + if _handler_has_arg(SDK_ARG_NAME, handler): + profile_name = kvps.pop(PROFILE_ARG_NAME, None) debug = kvps.pop(u"debug", None) profile = cliprofile.get_profile(profile_name) - kvps[u"sdk"] = create_sdk(profile, debug) + kvps[SDK_ARG_NAME] = create_sdk(profile, debug) - if _handler_has_arg(u"profile", handler): - kvps[u"profile"] = profile + if _handler_has_arg(PROFILE_ARG_NAME, handler): + kvps[PROFILE_ARG_NAME] = profile return kvps @@ -139,6 +138,6 @@ def _handler_has_arg(arg_name, handler): def _kvps_to_obj(kvps): - new_kvps = {key: kvps[key] for key in kvps if key in [u"sdk", u"profile"]} + new_kvps = {key: kvps[key] for key in kvps if key in [SDK_ARG_NAME, PROFILE_ARG_NAME]} new_kvps[u"args"] = DictObject(kvps) return new_kvps diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py new file mode 100644 index 000000000..fc14d6aa4 --- /dev/null +++ b/src/code42cli/errors.py @@ -0,0 +1,33 @@ +from code42cli.logger import get_error_logger, ERROR_LOG_FILE_NAME +from code42cli.util import is_interactive, print_error, get_user_project_path + + +ERRORED = False + + +def log_error(exception): + """Logs the error to the CLI error log file. If running interactively, it will also print a + message telling the user the location of the error log file.""" + logger = get_error_logger() + logger.error(exception) + global ERRORED + ERRORED = True + print_errors_occurred_if_needed() + + +def print_errors_occurred_if_needed(): + """If interactive and errors occurred, it will print a message telling the user how to retrieve + error logs.""" + if is_interactive() and ERRORED: + print_errors_occurred() + + +def print_errors_occurred(): + """Prints a message telling the user how to retrieve error logs.""" + print_error(get_error_message()) + + +def get_error_message(): + """Returns the error message that is printed when errors occur.""" + path = get_user_project_path(u"log") + return u"View exceptions that occurred at {}/{}.".format(path, ERROR_LOG_FILE_NAME) diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py index 95b9fbb28..d3460fc37 100644 --- a/src/code42cli/invoker.py +++ b/src/code42cli/invoker.py @@ -2,7 +2,11 @@ import sys +from py42.exceptions import Py42ForbiddenError + from code42cli.parser import ArgumentParserError, CommandParser +from code42cli.errors import log_error +from code42cli.util import print_error class CommandInvoker(object): @@ -19,9 +23,18 @@ def run(self, input_args): input_args (iter[str]): the full list of arguments supplied by the user to `code42` cli command. """ - path_parts = self._get_path_parts(input_args) - command = self._commands.get(u" ".join(path_parts)) - self._try_run_command(command, path_parts, input_args) + try: + path_parts = self._get_path_parts(input_args) + command = self._commands.get(u" ".join(path_parts)) + self._try_run_command(command, path_parts, input_args) + except Py42ForbiddenError as err: + log_error(err) + print_error( + u"You do not have the necessary permissions to perform this task. " + u"Try using or creating a different profile." + ) + except Exception as ex: + log_error(ex) def _get_path_parts(self, input_args): """Gets the portion of `input_args` that refers to a diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 940c110f9..5c593c8d1 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -7,20 +7,21 @@ logger_deps_lock = Lock() +ERROR_LOG_FILE_NAME = u"code42_errors.log" def get_error_logger(): """Gets the logger where exceptions are logged.""" log_path = get_user_project_path(u"log") - log_path = u"{0}/code42_errors.log".format(log_path) + log_path = u"{}/{}".format(log_path, ERROR_LOG_FILE_NAME) logger = logging.getLogger(u"code42_error_logger") if logger_has_handlers(logger): return logger with logger_deps_lock: if not logger_has_handlers(logger): - formatter = logging.Formatter("%(asctime)s %(message)s") - handler = RotatingFileHandler(log_path, maxBytes=250000000, encoding="utf-8") + formatter = logging.Formatter(u"%(asctime)s %(message)s") + handler = RotatingFileHandler(log_path, maxBytes=250000000, encoding=u"utf-8") return apply_logger_dependencies(logger, handler, formatter) return logger diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 8d420052d..97db2866f 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -4,11 +4,41 @@ from code42cli.logger import get_error_logger +class WorkerStats(object): + """Stats about the tasks that have run.""" + + _total = 0 + _total_errors = 0 + __total_lock = Lock() + __total_errors_lock = Lock() + + @property + def total(self): + """The total number of tasks executed.""" + return self._total + + @property + def total_errors(self): + """The amount of errors that occurred.""" + return self._total_errors + + def increment_total(self): + """+1 to self.total""" + with self.__total_lock: + self._total += 1 + + def increment_total_errors(self): + """+1 to self.total_errors""" + with self.__total_errors_lock: + self._total_errors += 1 + + class Worker(object): def __init__(self, thread_count): self._queue = queue.Queue() self._thread_count = thread_count self._error_logger = get_error_logger() + self._stats = WorkerStats() self.__started = False self.__start_lock = Lock() @@ -27,6 +57,12 @@ def do_async(self, func, *args, **kwargs): self.__started = True self._queue.put({u"func": func, u"args": args, u"kwargs": kwargs}) + @property + def stats(self): + """Stats about the tasks that have been executed, such as the total errors that occurred. + """ + return self._stats + def wait(self): """Wait for the tasks in the queue to complete. This should usually be called before program termination.""" @@ -42,7 +78,9 @@ def _process_queue(self): func(*args, **kwargs) except Exception as ex: self._error_logger.error(ex) + self._stats.increment_total_errors() finally: + self._stats.increment_total() self._queue.task_done() def __start(self): diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index ffde95280..506803114 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -1,63 +1,76 @@ import pytest -from code42cli.cmds.detectionlists.high_risk_employee import add_high_risk_employee +from code42cli.cmds.detectionlists import UserDoesNotExistError +from code42cli.cmds.detectionlists.high_risk_employee import ( + add_high_risk_employee, + remove_high_risk_employee, +) from .conftest import TEST_ID -def test_add_high_risk_employee_when_given_cloud_aliases_adds_alias(sdk_with_user, profile): - alias = "risk employee alias" - add_high_risk_employee(sdk_with_user, profile, "risky employee", cloud_alias=[alias]) - sdk_with_user.detectionlists.add_user_cloud_aliases.assert_called_once_with(TEST_ID, [alias]) +_EMPLOYEE = "risky employee" -def test_add_high_risk_employee_when_given_str_of_cloud_aliases_adds_aliases( - sdk_with_user, profile -): - add_high_risk_employee( - sdk_with_user, - profile, - "risky employee", - cloud_alias="1@example.com 2@example.com 3@example.com", - ) - sdk_with_user.detectionlists.add_user_cloud_aliases.assert_called_once_with( - TEST_ID, ["1@example.com", "2@example.com", "3@example.com"] - ) +def test_add_high_risk_employee_when_given_cloud_alias_adds_alias(sdk_with_user, profile): + alias = "risk employee alias" + add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, cloud_alias=alias) + sdk_with_user.detectionlists.add_user_cloud_alias.assert_called_once_with(TEST_ID, alias) def test_add_high_risk_employee_when_given_risk_tags_adds_tags(sdk_with_user, profile): - add_high_risk_employee(sdk_with_user, profile, "risky employee", risk_tag="RF1 RF2 RF3") + add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, risk_tag="tag1 tag2 tag3") sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, ["RF1", "RF2", "RF3"] + TEST_ID, ["tag1", "tag2", "tag3"] ) def test_add_high_risk_employee_when_given_str_of_risk_tags_adds_tags(sdk_with_user, profile): risk_tag = "BeingRisky" - add_high_risk_employee(sdk_with_user, profile, "risky employee", risk_tag=[risk_tag]) + add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, risk_tag=[risk_tag]) sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with(TEST_ID, [risk_tag]) def test_add_high_risk_employee_when_given_notes_updates_notes(sdk_with_user, profile): notes = "being risky" - add_high_risk_employee(sdk_with_user, profile, "risky employee", notes=notes) + add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, notes=notes) sdk_with_user.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) def test_add_high_risk_employee_adds(sdk_with_user, profile): - add_high_risk_employee(sdk_with_user, profile, "risky employee") + add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) sdk_with_user.detectionlists.high_risk_employee.add.assert_called_once_with(TEST_ID) def test_add_high_risk_employee_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(SystemExit): - add_high_risk_employee(sdk_without_user, profile, "risky employee") + with pytest.raises(UserDoesNotExistError): + add_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) + + +def test_add_high_risk_employee_when_user_does_not_exist_prints_error( + sdk_without_user, profile, capsys +): + try: + add_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) + except UserDoesNotExistError: + capture = capsys.readouterr() + assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out + + +def test_remove_high_risk_employee_calls_remove(sdk_with_user, profile): + remove_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) + sdk_with_user.detectionlists.high_risk_employee.remove.assert_called_once_with(TEST_ID) + + +def test_remove_high_risk_employee_when_user_does_not_exist_exits(sdk_without_user, profile): + with pytest.raises(UserDoesNotExistError): + remove_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) -def test_add_high_risk_employee_when_user_does_not_exist_print_error( +def test_remove_high_risk_employee_when_user_does_not_exist_prints_error( sdk_without_user, profile, capsys ): try: - add_high_risk_employee(sdk_without_user, profile, "risky employee") - except SystemExit: + remove_high_risk_employee(sdk_without_user, profile, "risky employee") + except UserDoesNotExistError: capture = capsys.readouterr() - assert "ERROR: User 'risky employee' does not exist." in capture.out + assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 807fe26ff..00f21aa33 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -6,7 +6,9 @@ DetectionListHandlers, get_user_id, update_user, + UserDoesNotExistError, ) +from code42cli.cmds.detectionlists.enums import BulkCommandType from .conftest import TEST_ID @@ -23,25 +25,23 @@ def bulk_processor(mocker): return mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) -def test_get_user_id_when_user_does_not_exist_exits(sdk_without_user): - with pytest.raises(SystemExit): +def test_get_user_id_when_user_does_not_raise_error(sdk_without_user): + with pytest.raises(UserDoesNotExistError): get_user_id(sdk_without_user, "risky employee") -def test_get_user_id_when_user_does_not_exist_print_error(sdk_without_user, capsys): +def test_get_user_id_when_user_does_not_exist_prints_error(sdk_without_user, capsys): try: get_user_id(sdk_without_user, "risky employee") - except SystemExit: + except UserDoesNotExistError: capture = capsys.readouterr() assert "ERROR: User 'risky employee' does not exist." in capture.out -def test_update_user_adds_cloud_aliases(sdk_with_user, profile): - update_user( - sdk_with_user, TEST_ID, cloud_alias=["1@example.com", "2@example.com", "3@example.com"] - ) - sdk_with_user.detectionlists.add_user_cloud_aliases.assert_called_once_with( - TEST_ID, ["1@example.com", "2@example.com", "3@example.com"] +def test_update_user_adds_cloud_alias(sdk_with_user, profile): + update_user(sdk_with_user, TEST_ID, cloud_alias="1@example.com") + sdk_with_user.detectionlists.add_user_cloud_alias.assert_called_once_with( + TEST_ID, "1@example.com" ) @@ -64,15 +64,40 @@ def test_load_commands_loads_expected_commands(self): cmds = detection_list.load_subcommands() assert cmds[0].name == "bulk" assert cmds[1].name == "add" + assert cmds[2].name == "remove" + + def test_generate_template_file_when_given_add_generates_template_from_handler( + self, bulk_template_generator + ): + def a_test_func(param1, param2, param3): + pass - def test_generate_csv_file_generates_template(self, bulk_template_generator): handlers = DetectionListHandlers() + handlers.add_employee = a_test_func detection_list = DetectionList("TestList", handlers) path = "some/path" - detection_list.generate_csv_file("add", path) - bulk_template_generator.assert_called_once_with(handlers.add_employee, path) + detection_list.generate_template_file(BulkCommandType.ADD, path) + bulk_template_generator.assert_called_once_with(a_test_func, path) + + def test_generate_template_file_when_given_remove_generates_template_from_handler( + self, bulk_template_generator + ): + def a_test_func(): + pass + + handlers = DetectionListHandlers() + handlers.remove_employee = a_test_func + detection_list = DetectionList("TestList", handlers) + path = "some/path" + detection_list.generate_template_file(BulkCommandType.REMOVE, path) + bulk_template_generator.assert_called_once_with(a_test_func, path) def test_bulk_add_employees_uses_csv_path(self, sdk, profile, bulk_processor): detection_list = DetectionList("TestList", DetectionListHandlers()) detection_list.bulk_add_employees(sdk, profile, "csv_test") assert bulk_processor.call_args[0][0] == "csv_test" + + def test_bulk_remove_employees_uses_file_path(self, sdk, profile, bulk_processor): + detection_list = DetectionList("TestList", DetectionListHandlers()) + detection_list.bulk_remove_employees(sdk, profile, "file_test") + assert bulk_processor.call_args[0][0] == "file_test" diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index 98f5ef15d..3aa2803c3 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -12,6 +12,7 @@ get_filter_value_from_json, get_test_date_str, ) +import code42cli.errors as errors @pytest.fixture @@ -515,34 +516,41 @@ def side_effect(): extraction_module.extract(sdk, profile, logger, namespace) -def test_extract_when_global_variable_is_true_and_is_interactive_prints_error( - sdk, profile, logger, namespace_with_begin, extractor, error_printer, interactive_mode +def test_extract_when_errored_and_is_interactive_prints_error( + mocker, sdk, profile, logger, namespace_with_begin, extractor ): - extraction_module._EXCEPTIONS_OCCURRED = True + errors.ERRORED = False + errors_error_printer = mocker.patch("{}.errors.print_error".format(PRODUCT_NAME)) + errors_interactive_mode = mocker.patch("{}.errors.is_interactive".format(PRODUCT_NAME)) + errors_interactive_mode.return_value = True + errors.ERRORED = True extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert error_printer.call_count + assert errors_error_printer.call_count + errors.ERRORED = False -def test_extract_when_global_variable_is_true_and_not_is_interactive_does_not_print_error( +def test_extract_when_errored_and_is_not_interactive_does_not_print_error( sdk, profile, logger, namespace_with_begin, extractor, error_printer, non_interactive_mode ): - extraction_module._EXCEPTIONS_OCCURRED = True + errors.ERRORED = True extraction_module.extract(sdk, profile, logger, namespace_with_begin) assert not error_printer.call_count + errors.ERRORED = False -def test_extract_when_global_variable_is_false_and_is_interactive_does_not_print_error( +def test_extract_when_not_errored_and_is_interactive_does_not_print_error( sdk, profile, logger, namespace_with_begin, extractor, error_printer, interactive_mode ): - extraction_module._EXCEPTIONS_OCCURRED = False + errors.ERRORED = False extraction_module.extract(sdk, profile, logger, namespace_with_begin) assert not error_printer.call_count + errors.ERRORED = False def test_when_sdk_raises_exception_global_variable_gets_set( mocker, sdk, profile, logger, namespace_with_begin, mock_42 ): - extraction_module._EXCEPTIONS_OCCURRED = False + errors.ERRORED = False mock_sdk = mocker.MagicMock() # For ease @@ -560,4 +568,5 @@ def sdk_side_effect(self, *args): ) extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert extraction_module._EXCEPTIONS_OCCURRED + assert errors.ERRORED + errors.ERRORED = False diff --git a/tests/conftest.py b/tests/conftest.py index 77c1128eb..726ee45fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ from argparse import Namespace import pytest -from code42cli.bulk import BulkProcessor from code42cli.config import ConfigAccessor from code42cli.profile import Code42Profile from py42.sdk import SDKClient diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 4168c9d67..d8e6d939f 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -2,7 +2,8 @@ from io import IOBase from code42cli import PRODUCT_NAME -from code42cli.bulk import generate_template, BulkProcessor, run_bulk_process +from code42cli import errors as errors +from code42cli.bulk import generate_template, BulkProcessor, run_bulk_process, CSVReader _NAMESPACE = "{}.bulk".format(PRODUCT_NAME) @@ -27,47 +28,166 @@ def bulk_processor_factory(mocker, bulk_processor): return mock_factory -def func_for_bulk(sdk, profile, test1, test2): +def func_with_multiple_args(sdk, profile, test1, test2): pass -def test_generate_template_uses_expected_path_and_column_names(mocker, mock_open): +def func_with_one_arg(sdk, profile, test1): + pass + + +def test_generate_template_uses_expected_path_and_column_names(mock_open): file_path = "some/path" template_file = mock_open.return_value.__enter__.return_value - generate_template(func_for_bulk, file_path) + generate_template(func_with_multiple_args, file_path) mock_open.assert_called_once_with(file_path, u"w", encoding=u"utf8") template_file.write.assert_called_once_with("test1,test2") -def test_generate_template_when_given_non_callable_handler_does_not_create(mock_open): - generate_template(None, "some/path") - assert not mock_open.call_count +def test_generate_template_when_handler_has_one_arg_creates_file_without_columns(mock_open): + file_path = "some/path" + template_file = mock_open.return_value.__enter__.return_value + + generate_template(func_with_one_arg, "some/path") + mock_open.assert_called_once_with(file_path, u"w", encoding=u"utf8") + assert not template_file.write.call_count + + +def test_generate_template_when_handler_has_one_arg_prints_message(mock_open, capsys): + generate_template(func_with_one_arg, "some/path") + capture = capsys.readouterr() + assert ( + u"A blank file was generated because there are no csv headers needed for this command. " + u"Simply enter one test1 per line." in capture.out + ) + + +def test_generate_template_when_handler_has_more_than_one_arg_does_not_print_message( + mock_open, capsys +): + generate_template(func_with_multiple_args, "some/path") + capture = capsys.readouterr() + assert ( + u"A blank file was generated because there are no csv headers needed for this command type." + not in capture.out + ) def test_run_bulk_process_calls_run(bulk_processor, bulk_processor_factory): - run_bulk_process("some/path", func_for_bulk) + errors.ERRORED = False + run_bulk_process("some/path", func_with_one_arg, None) assert bulk_processor.run.call_count def test_run_bulk_process_creates_processor(bulk_processor_factory): - run_bulk_process("some/path", func_for_bulk) - bulk_processor_factory.assert_called_once_with("some/path", func_for_bulk) + errors.ERRORED = False + reader = CSVReader() + run_bulk_process("some/path", func_with_one_arg, reader) + bulk_processor_factory.assert_called_once_with("some/path", func_with_one_arg, reader) + + +def test_run_bulk_process_when_not_given_reader_uses_csv_reader(bulk_processor_factory): + errors.ERRORED = False + run_bulk_process("some/path", func_with_one_arg) + assert type(bulk_processor_factory.call_args[0][2]) == CSVReader class TestBulkProcessor(object): - def test_run_processes_rows(self, mocker, mock_open): + def test_run_when_reader_returns_dict_process_kwargs(self, mock_open): + errors.ERRORED = False processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - dict_reader = mocker.patch("{}._create_dict_reader".format(_NAMESPACE)) - dict_reader.return_value = [ - {"test1": 1, "test2": 2}, - {"test1": 3, "test2": 4}, - {"test1": 5, "test2": 6}, - ] - processor = BulkProcessor("some/path", func_for_bulk) + class MockDictReader(object): + def __call__(self, *args, **kwargs): + return [ + {"test1": 1, "test2": 2}, + {"test1": 3, "test2": 4}, + {"test1": 5, "test2": 6}, + ] + + processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) processor.run() assert processed_rows == [(1, 2), (3, 4), (5, 6)] + + def test_run_when_dict_reader_has_none_for_key_ignores_key(self, mock_open): + errors.ERRORED = False + processed_rows = [] + + def func_for_bulk(test1): + processed_rows.append(test1) + + class MockDictReader(object): + def __call__(self, *args, **kwargs): + return [{"test1": 1, None: 2}] + + processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) + processor.run() + assert processed_rows == [1] + + def test_run_when_reader_returns_strs_processes_strs(self, mock_open): + errors.ERRORED = False + processed_rows = [] + + def func_for_bulk(test): + processed_rows.append(test) + + class MockRowReader(object): + def __call__(self, *args, **kwargs): + return ["row1", "row2", "row3"] + + processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + processor.run() + assert processed_rows == ["row1", "row2", "row3"] + + def test_run_when_error_occurs_prints_error_messages(self, mock_open, capsys): + errors.ERRORED = False + + def func_for_bulk(test): + if test == "row2": + raise Exception() + + class MockRowReader(object): + def __call__(self, *args, **kwargs): + return ["row1", "row2", "row3"] + + processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + processor.run() + capture = capsys.readouterr() + assert "2 processed successfully out of 3." in capture.out + assert errors.get_error_message() in capture.out + errors.ERRORED = False + + def test_run_when_no_errors_occur_prints_success_messages(self, mock_open, capsys): + errors.ERRORED = False + + def func_for_bulk(test): + pass + + class MockRowReader(object): + def __call__(self, *args, **kwargs): + return ["row1", "row2", "row3"] + + processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + processor.run() + capture = capsys.readouterr() + assert "3 processed successfully out of 3." in capture.out + assert errors.get_error_message() not in capture.out + + def test_run_when_row_is_endline_does_not_process_row(self, mock_open, capsys): + errors.ERRORED = False + + def func_for_bulk(test): + pass + + class MockRowReader(object): + def __call__(self, *args, **kwargs): + return ["row1", "row2", "\n"] + + processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + processor.run() + capture = capsys.readouterr() + assert "2 processed successfully out of 2." in capture.out diff --git a/tests/test_commands.py b/tests/test_commands.py index bf9459664..fd9b129a6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,6 @@ import pytest from code42cli import PRODUCT_NAME -from code42cli.args import ArgConfig +from code42cli.args import ArgConfig, SDK_ARG_NAME, PROFILE_ARG_NAME from code42cli.commands import Command, DictObject from code42cli.profile import Code42Profile from py42.sdk import SDKClient @@ -114,9 +114,9 @@ def test_get_arg_configs_when_handler_with_sdk_includes_profile_and_debug(self): assert "two" in coll["two"].settings["options_list"] assert "--three" in coll["three"].settings["options_list"] assert "--four" in coll["four"].settings["options_list"] - assert "--profile" in coll["profile"].settings["options_list"] + assert "--profile" in coll[PROFILE_ARG_NAME].settings["options_list"] assert "--debug" in coll["debug"].settings["options_list"] - assert not coll.get("sdk") + assert not coll.get(SDK_ARG_NAME) def test_get_arg_configs_when_handler_with_args_excludes_args(self): command = Command("test", "test desc", "test usage", func_with_args) diff --git a/tests/test_invoker.py b/tests/test_invoker.py index 39ac847cb..f04a5e27d 100644 --- a/tests/test_invoker.py +++ b/tests/test_invoker.py @@ -1,5 +1,8 @@ import pytest +from py42.exceptions import Py42ForbiddenError + +from code42cli import PRODUCT_NAME from code42cli.commands import Command from code42cli.invoker import CommandInvoker from code42cli.parser import ArgumentParserError, CommandParser @@ -57,3 +60,29 @@ def test_run_nested_cmd_when_raises_argumentparsererror_prints_help(self, mocker with pytest.raises(SystemExit): invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) assert mock_subparser.print_help.call_count + + def test_run_when_errors_occur_from_handler_calls_log_error(self, mocker, mock_parser): + error_logger = mocker.patch("{}.invoker.log_error".format(PRODUCT_NAME)) + ex = Exception() + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + mock_parser.parse_args.side_effect = ex + mock_subparser = mocker.MagicMock() + mock_parser.prepare_command.return_value = mock_subparser + invoker = CommandInvoker(cmd, mock_parser) + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + error_logger.assert_called_once_with(ex) + + def test_run_when_forbidden_error_occurs_prints_message(self, mocker, mock_parser, capsys): + mocker.patch("{}.invoker.log_error".format(PRODUCT_NAME)) + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + mock_parser.parse_args.side_effect = Py42ForbiddenError(Exception()) + mock_subparser = mocker.MagicMock() + mock_parser.prepare_command.return_value = mock_subparser + invoker = CommandInvoker(cmd, mock_parser) + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + + capture = capsys.readouterr() + assert ( + u"You do not have the necessary permissions to perform this task. Try using or " + u"creating a different profile." in capture.out + ) From 43c5e7959540df9028b0386af6c9111f8c1f6539 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 22 Apr 2020 10:38:30 -0500 Subject: [PATCH 035/349] allow partial time sections of the timestamp argument (#43) * allow partial time sections of the timestamp argument * update test * update error message to reflect flexible time format * update readme --- README.md | 8 ++++ .../cmds/securitydata/date_helper.py | 7 ++- tests/cmds/securitydata/test_date_helper.py | 44 +++++++++++-------- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8b77dff23..7710397f7 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,14 @@ To specify a begin/end time, you can pass a date or a date w/ time as a string: code42 security-data print -b '2020-02-02 12:51:00' ``` +```bash +code42 security-data print -b '2020-02-02 12:30' +``` + +```bash +code42 security-data print -b '2020-02-02 12' +``` + ```bash code42 security-data print -b 2020-02-02 ``` diff --git a/src/code42cli/cmds/securitydata/date_helper.py b/src/code42cli/cmds/securitydata/date_helper.py index 290e37199..49c946b58 100644 --- a/src/code42cli/cmds/securitydata/date_helper.py +++ b/src/code42cli/cmds/securitydata/date_helper.py @@ -5,7 +5,7 @@ from py42.sdk.queries.fileevents.filters.event_filter import EventTimestamp _MAX_LOOK_BACK_DAYS = 90 -_FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date in YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format, or a short value in days, hours, or minutes (e.g. 30d, 24h, 15m)" +_FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date/time string (e.g. 'YYYY-MM-DD', 'YY-MM-DD HH:MM', 'YY-MM-DD HH:MM:SS'), or a short value in days, hours, or minutes (e.g. 30d, 24h, 15m)" class DateArgumentException(Exception): @@ -91,7 +91,10 @@ def _parse_max_timestamp(end_date_str): def _get_dt_from_date_time_pair(date, time): date_format = u"%Y-%m-%d %H:%M:%S" - time = time or u"00:00:00" + if time: + time = u"{}:{}:{}".format(*time.split(":") + [u"00", u"00"]) + else: + time = u"00:00:00" date_string = u"{} {}".format(date, time) try: dt = datetime.strptime(date_string, date_format) diff --git a/tests/cmds/securitydata/test_date_helper.py b/tests/cmds/securitydata/test_date_helper.py index 997fa6e31..d04af6d1c 100644 --- a/tests/cmds/securitydata/test_date_helper.py +++ b/tests/cmds/securitydata/test_date_helper.py @@ -65,6 +65,18 @@ def test_create_event_timestamp_filter_when_given_both_begin_and_end_builds_expe assert actual_end == expected_end +def test_create_event_timestamp_filter_when_given_short_time_args_builds_expected_query(): + begin_date = "{} 10".format(begin_date_str) + end_date = "{} 12:37".format(end_date_str) + ts_range = create_event_timestamp_filter(begin_date, end_date) + actual_begin = get_filter_value_from_json(ts_range, filter_index=0) + actual_end = get_filter_value_from_json(ts_range, filter_index=1) + expected_begin = "{0}T10:00:00.000Z".format(begin_date_str) + expected_end = "{0}T12:37:00.000Z".format(end_date_str) + assert actual_begin == expected_begin + assert actual_end == expected_end + + def test_create_event_timestamp_filter_when_begin_more_than_ninety_days_back_causes_value_error(): begin_date_str = get_test_date_str(days_ago=91) with pytest.raises(DateArgumentException): @@ -90,22 +102,18 @@ def test_create_event_timestamp_filter_when_args_are_magic_days_builds_expected_ assert actual_end == expected_end -def test_create_event_timestamp_filter_when_given_improperly_formatted_arg_raises_value_error(): - missing_seconds = "{} {}".format(get_test_date_str(days_ago=5), "12:00") - month_first_date = "01-01-2020" - time_typo = "{} {}".format(get_test_date_str(days_ago=5), "b20:30:00") - bad_magic = "2months" - bad_magic_2 = "100s" - bad_magic_3 = "10 d" - with pytest.raises(DateArgumentException): - create_event_timestamp_filter(missing_seconds) - with pytest.raises(DateArgumentException): - create_event_timestamp_filter(month_first_date) - with pytest.raises(DateArgumentException): - create_event_timestamp_filter(time_typo) - with pytest.raises(DateArgumentException): - create_event_timestamp_filter(bad_magic) - with pytest.raises(DateArgumentException): - create_event_timestamp_filter(bad_magic_2) +@pytest.mark.parametrize( + "bad_date_param", + [ + "01-01-2020", + "{} {}".format(get_test_date_str(days_ago=5), "b20:30:00"), + "2months", + "100s", + "10 d", + ], +) +def test_create_event_timestamp_filter_when_given_improperly_formatted_arg_raises_value_error( + bad_date_param, +): with pytest.raises(DateArgumentException): - create_event_timestamp_filter(bad_magic_3) + create_event_timestamp_filter(bad_date_param) From 2f676306a79734e3fe04b5570172155af8b90622 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 22 Apr 2020 19:54:57 +0000 Subject: [PATCH 036/349] Feature/de (#40) --- CHANGELOG.md | 11 +- README.md | 3 +- src/code42cli/cmds/detectionlists/__init__.py | 15 ++- .../cmds/detectionlists/departing_employee.py | 48 ++++++++ .../cmds/securitydata/date_helper.py | 106 ++---------------- src/code42cli/date_helper.py | 91 +++++++++++++++ src/code42cli/main.py | 9 +- .../detectionlists/test_departing_employee.py | 65 +++++++++++ .../detectionlists/test_high_risk_employee.py | 2 +- tests/cmds/detectionlists/test_init.py | 1 - tests/cmds/securitydata/conftest.py | 25 +---- tests/cmds/securitydata/test_date_helper.py | 6 +- tests/cmds/securitydata/test_extraction.py | 13 +-- .../cmds/securitydata/test_logger_factory.py | 3 +- tests/cmds/securitydata/test_main.py | 5 +- tests/cmds/test_profile.py | 2 +- tests/conftest.py | 33 +++++- tests/test_bulk.py | 11 +- tests/test_commands.py | 4 +- tests/test_config.py | 1 - tests/test_date_helper.py | 82 ++++++++++++++ tests/test_password.py | 2 +- tests/test_profile.py | 4 +- tests/test_sdk_client.py | 2 +- 24 files changed, 389 insertions(+), 155 deletions(-) create mode 100644 src/code42cli/cmds/detectionlists/departing_employee.py create mode 100644 src/code42cli/date_helper.py create mode 100644 tests/cmds/detectionlists/test_departing_employee.py create mode 100644 tests/test_date_helper.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db1abf521..a2b666d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,12 +30,21 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 high-risk-employee` commands: - `bulk` with subcommands: - `add`: that takes a csv file of users. - - `generate-template`: that creates the csv file template. And parameters: + - `generate-template`: that creates the file template. And parameters: - `cmd`: with options `add` and `remove`. - `path` - `remove`: that takes a list of users in a file. - `add` that takes parameters: `--username`, `--cloud-alias`, `--risk-factor`, and `--notes`. - `remove` that takes a username. +- `code42 departing-employee` commands: + - `bulk` with subcommands: + - `add`: that takes a csv file of users. + - `generate-template`: that creates the file template. And parameters: + - `cmd`: with options `add` and `remove`. + - `path` + - `remove`: that takes a list of users in a file. + - `add` that takes parameters: `--username`, `--cloud-alias`, `--departure-date`, and `--notes`. + - `remove` that takes a username. ### Removed diff --git a/README.md b/README.md index 7710397f7..0d0f3808e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Use the `code42` command to interact with your Code42 environment. * `code42 security-data` is a CLI tool for extracting AED events. Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a checkpoint (provided you do not change your query). -* `code42 high-risk-employee` is a collection of tools for managing the high risk employee detection list. +* `code42 high-risk-employee` is a collection of tools for managing the high risk employee detection list. Similarly, + there is `code42 departing-employee`. ## Requirements diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index ec7a1bfa5..2a419d3d3 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -63,6 +63,19 @@ def create_high_risk_employee_list(cls, handlers): """ return cls(DetectionLists.HIGH_RISK_EMPLOYEE, handlers) + @classmethod + def create_departing_employee_list(cls, handlers): + """Creates a departing employee detection list. + + Args: + handlers (DetectionListHandlers): A DTO containing implementations for adding / + removing users from specific lists. + + Returns: + DetectionList: A departing employee detection list. + """ + return cls(DetectionLists.DEPARTING_EMPLOYEE, handlers) + def load_subcommands(self): """Loads high risk employee related subcommands""" bulk = self.factory.create_bulk_command(lambda: self._load_bulk_subcommands()) @@ -151,7 +164,7 @@ def load_user_descriptions(argument_collection): _load_username_description(argument_collection) cloud_alias = argument_collection.arg_configs[DetectionListUserKeys.CLOUD_ALIAS] notes = argument_collection.arg_configs[DetectionListUserKeys.NOTES] - cloud_alias.set_help(u"Alternative emails addresses for other cloud services.") + cloud_alias.set_help(u"An alternative email address for another cloud service.") notes.set_help(u"Notes about the employee.") diff --git a/src/code42cli/cmds/detectionlists/departing_employee.py b/src/code42cli/cmds/detectionlists/departing_employee.py new file mode 100644 index 000000000..19d73ef1a --- /dev/null +++ b/src/code42cli/cmds/detectionlists/departing_employee.py @@ -0,0 +1,48 @@ +from code42cli.cmds.detectionlists import ( + DetectionList, + DetectionListHandlers, + load_user_descriptions, + get_user_id, + update_user, +) + + +def load_subcommands(): + handlers = _create_handlers() + detection_list = DetectionList.create_departing_employee_list(handlers) + return detection_list.load_subcommands() + + +def _create_handlers(): + return DetectionListHandlers( + add=add_departing_employee, remove=remove_departing_employee, load_add=_load_add_description + ) + + +def add_departing_employee( + sdk, profile, username, cloud_alias=None, departure_date=None, notes=None +): + """Adds an employee to the departing employee detection list. + + Args: + sdk (py42.sdk.SDKClient): py42. + profile (C42Profile): Your code42 profile. + username (str): The username of the employee to add. + cloud_alias (str): An alternative email address for another cloud service. + departure_date (str): The date the employee is departing in format `YYYY-MM-DD`. + notes: (str): Notes about the employee. + """ + user_id = get_user_id(sdk, username) + update_user(sdk, user_id, cloud_alias, notes=notes) + sdk.detectionlists.departing_employee.add(user_id, departure_date) + + +def remove_departing_employee(sdk, profile, username): + user_id = get_user_id(sdk, username) + sdk.detectionlists.departing_employee.remove(user_id) + + +def _load_add_description(argument_collection): + load_user_descriptions(argument_collection) + departure_date = argument_collection.arg_configs[u"departure_date"] + departure_date.set_help(u"The date the employee is departing in format YYYY-MM-DD.") diff --git a/src/code42cli/cmds/securitydata/date_helper.py b/src/code42cli/cmds/securitydata/date_helper.py index 49c946b58..b86e5991d 100644 --- a/src/code42cli/cmds/securitydata/date_helper.py +++ b/src/code42cli/cmds/securitydata/date_helper.py @@ -1,20 +1,6 @@ -import re -from datetime import datetime, timedelta - -from c42eventextractor.common import convert_datetime_to_timestamp from py42.sdk.queries.fileevents.filters.event_filter import EventTimestamp -_MAX_LOOK_BACK_DAYS = 90 -_FORMAT_VALUE_ERROR_MESSAGE = u"input must be a date/time string (e.g. 'YYYY-MM-DD', 'YY-MM-DD HH:MM', 'YY-MM-DD HH:MM:SS'), or a short value in days, hours, or minutes (e.g. 30d, 24h, 15m)" - - -class DateArgumentException(Exception): - def __init__(self, message=_FORMAT_VALUE_ERROR_MESSAGE): - super(DateArgumentException, self).__init__(message) - - -TIMESTAMP_REGEX = re.compile(u"(\d{4}-\d{2}-\d{2})\s*(.*)?") -MAGIC_TIME_REGEX = re.compile(u"(\d+)([dhm])$") +from code42cli.date_helper import DateArgumentException, parse_min_timestamp, parse_max_timestamp def create_event_timestamp_filter(begin_date=None, end_date=None): @@ -25,16 +11,16 @@ def create_event_timestamp_filter(begin_date=None, end_date=None): end_date: The end date for the range. """ if begin_date and end_date: - min_timestamp = _parse_min_timestamp(begin_date) - max_timestamp = _parse_max_timestamp(end_date) + min_timestamp = parse_min_timestamp(begin_date) + max_timestamp = parse_max_timestamp(end_date) return _create_in_range_filter(min_timestamp, max_timestamp) elif begin_date and not end_date: - min_timestamp = _parse_min_timestamp(begin_date) + min_timestamp = parse_min_timestamp(begin_date) return _create_on_or_after_filter(min_timestamp) elif end_date and not begin_date: - max_timestamp = _parse_max_timestamp(end_date) + max_timestamp = parse_max_timestamp(end_date) return _create_on_or_before_filter(max_timestamp) @@ -43,80 +29,6 @@ def _create_in_range_filter(min_timestamp, max_timestamp): return EventTimestamp.in_range(min_timestamp, max_timestamp) -def _create_on_or_after_filter(min_timestamp): - return EventTimestamp.on_or_after(min_timestamp) - - -def _create_on_or_before_filter(max_timestamp): - return EventTimestamp.on_or_before(max_timestamp) - - -def _parse_timestamp(date_str, rounding_func): - timestamp_match = TIMESTAMP_REGEX.match(date_str) - magic_match = MAGIC_TIME_REGEX.match(date_str) - - if timestamp_match: - date, time = timestamp_match.groups() - dt = _get_dt_from_date_time_pair(date, time) - if not time: - dt = rounding_func(dt) - - elif magic_match: - num, period = magic_match.groups() - dt = _get_dt_from_magic_time_pair(num, period) - if period == u"d": - dt = rounding_func(dt) - - else: - raise DateArgumentException() - return dt - - -def _parse_min_timestamp(begin_date_str): - dt = _parse_timestamp(begin_date_str, _round_datetime_to_day_start) - - boundary_date = _round_datetime_to_day_start( - datetime.utcnow() - timedelta(days=_MAX_LOOK_BACK_DAYS) - ) - if dt < boundary_date: - raise DateArgumentException(u"'Begin date' must be within 90 days.") - - return convert_datetime_to_timestamp(dt) - - -def _parse_max_timestamp(end_date_str): - dt = _parse_timestamp(end_date_str, _round_datetime_to_day_end) - return convert_datetime_to_timestamp(dt) - - -def _get_dt_from_date_time_pair(date, time): - date_format = u"%Y-%m-%d %H:%M:%S" - if time: - time = u"{}:{}:{}".format(*time.split(":") + [u"00", u"00"]) - else: - time = u"00:00:00" - date_string = u"{} {}".format(date, time) - try: - dt = datetime.strptime(date_string, date_format) - except ValueError: - raise DateArgumentException() - else: - return dt - - -def _get_dt_from_magic_time_pair(num, period): - num = int(num) - if period == u"d": - dt = datetime.utcnow() - timedelta(days=num) - elif period == u"h": - dt = datetime.utcnow() - timedelta(hours=num) - elif period == u"m": - dt = datetime.utcnow() - timedelta(minutes=num) - else: - raise DateArgumentException(u"Couldn't parse magic time string: {}{}".format(num, period)) - return dt - - def _verify_timestamp_order(min_timestamp, max_timestamp): if min_timestamp is None or max_timestamp is None: return @@ -124,9 +36,9 @@ def _verify_timestamp_order(min_timestamp, max_timestamp): raise DateArgumentException(u"Begin date cannot be after end date") -def _round_datetime_to_day_start(dt): - return dt.replace(hour=0, minute=0, second=0, microsecond=0) +def _create_on_or_after_filter(min_timestamp): + return EventTimestamp.on_or_after(min_timestamp) -def _round_datetime_to_day_end(dt): - return dt.replace(hour=23, minute=59, second=59, microsecond=999000) +def _create_on_or_before_filter(max_timestamp): + return EventTimestamp.on_or_before(max_timestamp) diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py new file mode 100644 index 000000000..af9a222ee --- /dev/null +++ b/src/code42cli/date_helper.py @@ -0,0 +1,91 @@ +from datetime import datetime, timedelta +import re + +from c42eventextractor.common import convert_datetime_to_timestamp + + +_FORMAT_VALUE_ERROR_MESSAGE = ( + u"input must be a date/time string (e.g. 'YYYY-MM-DD', " + u"'YY-MM-DD HH:MM', 'YY-MM-DD HH:MM:SS'), or a short value in days, " + u"hours, or minutes (e.g. 30d, 24h, 15m)" +) + +TIMESTAMP_REGEX = re.compile(u"(\d{4}-\d{2}-\d{2})\s*(.*)?") +MAGIC_TIME_REGEX = re.compile(u"(\d+)([dhm])$") + + +class DateArgumentException(Exception): + def __init__(self, message=_FORMAT_VALUE_ERROR_MESSAGE): + super(DateArgumentException, self).__init__(message) + + +def parse_min_timestamp(begin_date_str, max_days_back=90): + dt = _parse_timestamp(begin_date_str, _round_datetime_to_day_start) + + boundary_date = _round_datetime_to_day_start(datetime.utcnow() - timedelta(days=max_days_back)) + if dt < boundary_date: + raise DateArgumentException(u"'Begin date' must be within 90 days.") + + return convert_datetime_to_timestamp(dt) + + +def parse_max_timestamp(end_date_str): + dt = _parse_timestamp(end_date_str, _round_datetime_to_day_end) + return convert_datetime_to_timestamp(dt) + + +def _parse_timestamp(date_str, rounding_func): + timestamp_match = TIMESTAMP_REGEX.match(date_str) + magic_match = MAGIC_TIME_REGEX.match(date_str) + + if timestamp_match: + date, time = timestamp_match.groups() + dt = _get_dt_from_date_time_pair(date, time) + if not time: + dt = rounding_func(dt) + + elif magic_match: + num, period = magic_match.groups() + dt = _get_dt_from_magic_time_pair(num, period) + if period == u"d": + dt = rounding_func(dt) + + else: + raise DateArgumentException() + return dt + + +def _get_dt_from_date_time_pair(date, time): + date_format = u"%Y-%m-%d %H:%M:%S" + if time: + time = u"{}:{}:{}".format(*time.split(":") + [u"00", u"00"]) + else: + time = u"00:00:00" + date_string = u"{} {}".format(date, time) + try: + dt = datetime.strptime(date_string, date_format) + except ValueError: + raise DateArgumentException() + else: + return dt + + +def _get_dt_from_magic_time_pair(num, period): + num = int(num) + if period == u"d": + dt = datetime.utcnow() - timedelta(days=num) + elif period == u"h": + dt = datetime.utcnow() - timedelta(hours=num) + elif period == u"m": + dt = datetime.utcnow() - timedelta(minutes=num) + else: + raise DateArgumentException(u"Couldn't parse magic time string: {}{}".format(num, period)) + return dt + + +def _round_datetime_to_day_start(dt): + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _round_datetime_to_day_end(dt): + return dt.replace(hour=23, minute=59, second=59, microsecond=999000) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 04ff3aef1..2ce19bb00 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -5,7 +5,9 @@ from code42cli import PRODUCT_NAME from code42cli.cmds import profile +from code42cli.cmds.detectionlists import departing_employee as de from code42cli.cmds.detectionlists import high_risk_employee as hre +from code42cli.cmds.detectionlists.enums import DetectionLists from code42cli.cmds.securitydata import main as secmain from code42cli.commands import Command from code42cli.invoker import CommandInvoker @@ -46,7 +48,12 @@ def _load_top_commands(): subcommand_loader=secmain.load_subcommands, ), Command( - u"high-risk-employee", + DetectionLists.DEPARTING_EMPLOYEE, + detection_lists_description.format(u"departing employee"), + subcommand_loader=de.load_subcommands, + ), + Command( + DetectionLists.HIGH_RISK_EMPLOYEE, detection_lists_description.format(u"high risk employee"), subcommand_loader=hre.load_subcommands, ), diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py new file mode 100644 index 000000000..15e2d0746 --- /dev/null +++ b/tests/cmds/detectionlists/test_departing_employee.py @@ -0,0 +1,65 @@ +import pytest + +from code42cli.cmds.detectionlists import UserDoesNotExistError +from code42cli.cmds.detectionlists.departing_employee import ( + add_departing_employee, + remove_departing_employee, +) +from .conftest import TEST_ID + + +_EMPLOYEE = "departing employee" + + +def test_add_departing_employee_when_given_cloud_alias_adds_alias(sdk_with_user, profile): + alias = "departing employee alias" + add_departing_employee(sdk_with_user, profile, _EMPLOYEE, cloud_alias=[alias]) + sdk_with_user.detectionlists.add_user_cloud_alias.assert_called_once_with(TEST_ID, [alias]) + + +def test_add_departing_employee_when_given_notes_updates_notes(sdk_with_user, profile): + notes = "is leaving" + add_departing_employee(sdk_with_user, profile, _EMPLOYEE, notes=notes) + sdk_with_user.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) + + +def test_add_departing_employee_adds(sdk_with_user, profile): + add_departing_employee(sdk_with_user, profile, _EMPLOYEE, departure_date="2020-02-02") + sdk_with_user.detectionlists.departing_employee.add.assert_called_once_with( + TEST_ID, "2020-02-02" + ) + + +def test_add_departing_employee_when_user_does_not_exist_exits(sdk_without_user, profile): + with pytest.raises(UserDoesNotExistError): + add_departing_employee(sdk_without_user, profile, _EMPLOYEE) + + +def test_add_departing_employee_when_user_does_not_exist_prints_error( + sdk_without_user, profile, capsys +): + try: + add_departing_employee(sdk_without_user, profile, _EMPLOYEE) + except UserDoesNotExistError: + capture = capsys.readouterr() + assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out + + +def test_remove_departing_employee_calls_remove(sdk_with_user, profile): + remove_departing_employee(sdk_with_user, profile, _EMPLOYEE) + sdk_with_user.detectionlists.departing_employee.remove.assert_called_once_with(TEST_ID) + + +def test_remove_departing_employee_when_user_does_not_exist_exits(sdk_without_user, profile): + with pytest.raises(UserDoesNotExistError): + remove_departing_employee(sdk_without_user, profile, _EMPLOYEE) + + +def test_remove_departing_employee_when_user_does_not_exist_prints_error( + sdk_without_user, profile, capsys +): + try: + remove_departing_employee(sdk_without_user, profile, _EMPLOYEE) + except UserDoesNotExistError: + capture = capsys.readouterr() + assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index 506803114..8706e66bc 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -70,7 +70,7 @@ def test_remove_high_risk_employee_when_user_does_not_exist_prints_error( sdk_without_user, profile, capsys ): try: - remove_high_risk_employee(sdk_without_user, profile, "risky employee") + remove_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) except UserDoesNotExistError: capture = capsys.readouterr() assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 00f21aa33..25f9493b3 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -11,7 +11,6 @@ from code42cli.cmds.detectionlists.enums import BulkCommandType from .conftest import TEST_ID - _NAMESPACE = "{}.cmds.detectionlists".format(PRODUCT_NAME) diff --git a/tests/cmds/securitydata/conftest.py b/tests/cmds/securitydata/conftest.py index 977ecf3e5..639e8e4a9 100644 --- a/tests/cmds/securitydata/conftest.py +++ b/tests/cmds/securitydata/conftest.py @@ -1,9 +1,9 @@ import json as json_module -from datetime import datetime, timedelta import pytest from code42cli import PRODUCT_NAME +from ...conftest import convert_str_to_date SECURITYDATA_NAMESPACE = "{}.cmds.securitydata".format(PRODUCT_NAME) @@ -17,29 +17,6 @@ def parse_date_from_filter_value(json, filter_index): return convert_str_to_date(date_str) -def convert_str_to_date(date_str): - return datetime.strptime(date_str, u"%Y-%m-%dT%H:%M:%S.%fZ") - - -def get_test_date(days_ago): - now = datetime.utcnow() - return now - timedelta(days=days_ago) - - -def get_test_date_str(days_ago): - return get_test_date(days_ago).strftime("%Y-%m-%d") - - -begin_date_str = get_test_date_str(days_ago=89) -begin_date_str_with_time = "{0} 3:12:33".format(begin_date_str) -end_date_str = get_test_date_str(days_ago=10) -end_date_str_with_time = "{0} 11:22:43".format(end_date_str) -begin_date_str = get_test_date_str(days_ago=89) -begin_date_with_time = [get_test_date_str(days_ago=89), "3:12:33"] -end_date_str = get_test_date_str(days_ago=10) -end_date_with_time = [get_test_date_str(days_ago=10), "11:22:43"] - - @pytest.fixture(autouse=True) def sqlite_connection(mocker): return mocker.patch("sqlite3.connect") diff --git a/tests/cmds/securitydata/test_date_helper.py b/tests/cmds/securitydata/test_date_helper.py index d04af6d1c..0e1d87df9 100644 --- a/tests/cmds/securitydata/test_date_helper.py +++ b/tests/cmds/securitydata/test_date_helper.py @@ -1,15 +1,15 @@ import pytest + from code42cli.cmds.securitydata.date_helper import ( create_event_timestamp_filter, DateArgumentException, ) - -from .conftest import ( +from .conftest import get_filter_value_from_json +from ...conftest import ( begin_date_str, begin_date_with_time, end_date_str, end_date_with_time, - get_filter_value_from_json, get_test_date_str, ) diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index 3aa2803c3..98c0cd594 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -1,18 +1,13 @@ import pytest - from py42.sdk import SDKClient from py42.sdk.queries.fileevents.filters import * -from code42cli import PRODUCT_NAME -from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions import code42cli.cmds.securitydata.extraction as extraction_module -from .conftest import ( - SECURITYDATA_NAMESPACE, - begin_date_str, - get_filter_value_from_json, - get_test_date_str, -) import code42cli.errors as errors +from code42cli import PRODUCT_NAME +from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions +from .conftest import SECURITYDATA_NAMESPACE, get_filter_value_from_json +from ...conftest import get_test_date_str, begin_date_str @pytest.fixture diff --git a/tests/cmds/securitydata/test_logger_factory.py b/tests/cmds/securitydata/test_logger_factory.py index aedb9eeb7..09d62e989 100644 --- a/tests/cmds/securitydata/test_logger_factory.py +++ b/tests/cmds/securitydata/test_logger_factory.py @@ -1,6 +1,5 @@ import logging -import code42cli.cmds.securitydata.logger_factory as factory import pytest from c42eventextractor.logging.formatters import ( FileEventDictToCEFFormatter, @@ -8,6 +7,8 @@ FileEventDictToRawJSONFormatter, ) +import code42cli.cmds.securitydata.logger_factory as factory + @pytest.fixture def no_priority_syslog_handler(mocker): diff --git a/tests/cmds/securitydata/test_main.py b/tests/cmds/securitydata/test_main.py index b1dafcf52..4d05dfb30 100644 --- a/tests/cmds/securitydata/test_main.py +++ b/tests/cmds/securitydata/test_main.py @@ -1,7 +1,8 @@ -from code42cli import PRODUCT_NAME -import code42cli.cmds.securitydata.main as main import pytest +import code42cli.cmds.securitydata.main as main +from code42cli import PRODUCT_NAME + @pytest.fixture def mock_logger_factory(mocker): diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index d380c097d..d730b7729 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,6 +1,6 @@ -import code42cli.cmds.profile as profilecmd import pytest +import code42cli.cmds.profile as profilecmd from code42cli import PRODUCT_NAME from ..conftest import create_mock_profile diff --git a/tests/conftest.py b/tests/conftest.py index 726ee45fb..f484f6888 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,11 @@ from argparse import Namespace +from datetime import datetime, timedelta import pytest +from py42.sdk import SDKClient + from code42cli.config import ConfigAccessor from code42cli.profile import Code42Profile -from py42.sdk import SDKClient @pytest.fixture @@ -99,3 +101,32 @@ def func_with_sdk(sdk, one, two, three=None, four=None): def func_with_args(args): pass + + +def convert_str_to_date(date_str): + return datetime.strptime(date_str, u"%Y-%m-%dT%H:%M:%S.%fZ") + + +def get_test_date(days_ago=None, hours_ago=None, minutes_ago=None): + """Note: only pass in one parameter to get the right test date... this is just a test func.""" + now = datetime.utcnow() + if days_ago: + return now - timedelta(days=days_ago) + if hours_ago: + return now - timedelta(hours=hours_ago) + if minutes_ago: + return now - timedelta(minutes=minutes_ago) + + +def get_test_date_str(days_ago): + return get_test_date(days_ago).strftime("%Y-%m-%d") + + +begin_date_str = get_test_date_str(days_ago=89) +begin_date_str_with_time = "{0} 3:12:33".format(begin_date_str) +end_date_str = get_test_date_str(days_ago=10) +end_date_str_with_time = "{0} 11:22:43".format(end_date_str) +begin_date_str = get_test_date_str(days_ago=89) +begin_date_with_time = [get_test_date_str(days_ago=89), "3:12:33"] +end_date_str = get_test_date_str(days_ago=10) +end_date_with_time = [get_test_date_str(days_ago=10), "11:22:43"] diff --git a/tests/test_bulk.py b/tests/test_bulk.py index d8e6d939f..318765353 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -1,11 +1,10 @@ -import pytest from io import IOBase +import pytest from code42cli import PRODUCT_NAME from code42cli import errors as errors from code42cli.bulk import generate_template, BulkProcessor, run_bulk_process, CSVReader - _NAMESPACE = "{}.bulk".format(PRODUCT_NAME) @@ -111,7 +110,9 @@ def __call__(self, *args, **kwargs): processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) processor.run() - assert processed_rows == [(1, 2), (3, 4), (5, 6)] + assert (1, 2) in processed_rows + assert (3, 4) in processed_rows + assert (5, 6) in processed_rows def test_run_when_dict_reader_has_none_for_key_ignores_key(self, mock_open): errors.ERRORED = False @@ -141,7 +142,9 @@ def __call__(self, *args, **kwargs): processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) processor.run() - assert processed_rows == ["row1", "row2", "row3"] + assert "row1" in processed_rows + assert "row2" in processed_rows + assert "row3" in processed_rows def test_run_when_error_occurs_prints_error_messages(self, mock_open, capsys): errors.ERRORED = False diff --git a/tests/test_commands.py b/tests/test_commands.py index fd9b129a6..6423075d1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,10 +1,10 @@ import pytest +from py42.sdk import SDKClient + from code42cli import PRODUCT_NAME from code42cli.args import ArgConfig, SDK_ARG_NAME, PROFILE_ARG_NAME from code42cli.commands import Command, DictObject from code42cli.profile import Code42Profile -from py42.sdk import SDKClient - from .conftest import ( func_keyword_args, func_mixed_args, diff --git a/tests/test_config.py b/tests/test_config.py index 20e06e619..56bf2eb9a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,6 @@ from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection - _TEST_PROFILE_NAME = "ProfileA" _TEST_SECOND_PROFILE_NAME = "ProfileB" _INTERNAL = "Internal" diff --git a/tests/test_date_helper.py b/tests/test_date_helper.py new file mode 100644 index 000000000..ecbe2b8c3 --- /dev/null +++ b/tests/test_date_helper.py @@ -0,0 +1,82 @@ +from datetime import datetime + +from c42eventextractor.common import convert_datetime_to_timestamp + +from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp +from .conftest import ( + begin_date_str, + begin_date_str_with_time, + end_date_str, + end_date_str_with_time, + get_test_date, +) + + +def test_parse_min_timestamp_when_given_date_str_parses_successfully(): + actual = parse_min_timestamp(begin_date_str) + expected = convert_datetime_to_timestamp(datetime.strptime(begin_date_str, "%Y-%m-%d")) + assert actual == expected + + +def test_parse_min_timestamp_when_given_date_str_with_time_parses_successfully(): + actual = parse_min_timestamp(begin_date_str_with_time) + expected = convert_datetime_to_timestamp( + datetime.strptime(begin_date_str_with_time, "%Y-%m-%d %H:%M:%S") + ) + assert actual == expected + + +def test_parse_min_timestamp_when_given_magic_days_parses_successfully(): + actual_date = datetime.utcfromtimestamp(parse_min_timestamp("20d")) + expected_date = datetime.utcfromtimestamp( + convert_datetime_to_timestamp(get_test_date(days_ago=20)) + ) + expected_date = expected_date.replace(hour=0, minute=0, second=0, microsecond=0) + assert actual_date == expected_date + + +def test_parse_min_timestamp_when_given_magic_hours_parses_successfully(): + actual = parse_min_timestamp("20h") + expected = convert_datetime_to_timestamp(get_test_date(hours_ago=20)) + assert expected - actual < 0.01 + + +def test_parse_min_timestamp_when_given_magic_minutes_parses_successfully(): + actual = parse_min_timestamp("20m") + expected = convert_datetime_to_timestamp(get_test_date(minutes_ago=20)) + assert expected - actual < 0.01 + + +def test_parse_max_timestamp_when_given_date_str_parses_successfully(): + actual = parse_min_timestamp(end_date_str) + expected = convert_datetime_to_timestamp(datetime.strptime(end_date_str, "%Y-%m-%d")) + assert actual == expected + + +def test_parse_max_timestamp_when_given_date_str_with_time_parses_successfully(): + actual = parse_min_timestamp(end_date_str_with_time) + expected = convert_datetime_to_timestamp( + datetime.strptime(end_date_str_with_time, "%Y-%m-%d %H:%M:%S") + ) + assert actual == expected + + +def test_parse_max_timestamp_when_given_magic_days_parses_successfully(): + actual_date = datetime.utcfromtimestamp(parse_max_timestamp("20d")) + expected_date = datetime.utcfromtimestamp( + convert_datetime_to_timestamp(get_test_date(days_ago=20)) + ) + expected_date = expected_date.replace(hour=23, minute=59, second=59, microsecond=999000) + assert actual_date == expected_date + + +def test_parse_max_timestamp_when_given_magic_hours_parses_successfully(): + actual = parse_max_timestamp("20h") + expected = convert_datetime_to_timestamp(get_test_date(hours_ago=20)) + assert expected - actual < 0.01 + + +def test_parse_magic_minutes_parses_successfully(): + actual = parse_max_timestamp("20m") + expected = convert_datetime_to_timestamp(get_test_date(minutes_ago=20)) + assert expected - actual < 0.01 diff --git a/tests/test_password.py b/tests/test_password.py index 1ebc60fd5..5c5fc0fcc 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,6 +1,6 @@ -import code42cli.password as password import pytest +import code42cli.password as password from code42cli import PRODUCT_NAME _USERNAME = "test.username" diff --git a/tests/test_profile.py b/tests/test_profile.py index 557510ad3..d4dff1fe9 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,9 +1,9 @@ import pytest -from code42cli import PRODUCT_NAME import code42cli.profile as cliprofile -from code42cli.config import ConfigAccessor, NoConfigProfileError +from code42cli import PRODUCT_NAME from code42cli.cmds.shared.cursor_store import FileEventCursorStore +from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection, create_mock_profile diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 9fb7c222d..347698b24 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,8 +1,8 @@ import py42.sdk import py42.settings.debug as debug import pytest -from code42cli.sdk_client import create_sdk, validate_connection +from code42cli.sdk_client import create_sdk, validate_connection from .conftest import create_mock_profile From 3eb66a1223f78351a46e1d45f006d812cab005b7 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 22 Apr 2020 20:17:33 +0000 Subject: [PATCH 037/349] risk tags cmds (#41) --- CHANGELOG.md | 4 +- src/code42cli/__version__.py | 2 +- src/code42cli/cmds/detectionlists/__init__.py | 8 ++- .../cmds/detectionlists/high_risk_employee.py | 58 +++++++++++++++++-- .../detectionlists/test_high_risk_employee.py | 56 ++++++++++++++++++ 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b666d7f..16b7cf1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 0.5.0 - Unreleased ### Changed @@ -36,6 +36,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `remove`: that takes a list of users in a file. - `add` that takes parameters: `--username`, `--cloud-alias`, `--risk-factor`, and `--notes`. - `remove` that takes a username. + - `add-risk-tags` that takes a username and risk tags. + - `remove-risk-tags` that takes a username and risk tags. - `code42 departing-employee` commands: - `bulk` with subcommands: - `add`: that takes a csv file of users. diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index cd1ee63b7..3d187266f 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.4.4" +__version__ = "0.5.0" diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 2a419d3d3..c9d1b5db9 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -83,7 +83,7 @@ def load_subcommands(self): self.handlers.add_employee, self.handlers.load_add_description ) remove = self.factory.create_remove_command( - self.handlers.remove_employee, _load_username_description + self.handlers.remove_employee, load_username_description ) return [bulk, add, remove] @@ -148,7 +148,9 @@ def _remove_employee(self, sdk, profile, *args, **kwargs): self.handlers.remove_employee(sdk, profile, *args, **kwargs) -def _load_username_description(argument_collection): + +def load_username_description(argument_collection): + """Loads the arg descriptions for the `username` CLI parameter.""" username = argument_collection.arg_configs[DetectionListUserKeys.USERNAME] username.set_help(u"A code42 username for an employee.") @@ -161,7 +163,7 @@ def load_user_descriptions(argument_collection): argument_collection (ArgConfigCollection): The arg configs off the command that needs its user descriptions loaded. """ - _load_username_description(argument_collection) + load_username_description(argument_collection) cloud_alias = argument_collection.arg_configs[DetectionListUserKeys.CLOUD_ALIAS] notes = argument_collection.arg_configs[DetectionListUserKeys.NOTES] cloud_alias.set_help(u"An alternative email address for another cloud service.") diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index 7bc73caeb..2bd6d0d01 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -2,16 +2,36 @@ DetectionList, DetectionListHandlers, load_user_descriptions, + load_username_description, get_user_id, update_user, ) from code42cli.cmds.detectionlists.enums import DetectionListUserKeys +from code42cli.commands import Command def load_subcommands(): + handlers = _create_handlers() detection_list = DetectionList.create_high_risk_employee_list(handlers) - return detection_list.load_subcommands() + cmd_list = detection_list.load_subcommands() + cmd_list.extend( + [ + Command( + u"add-risk-tags", + u"Associates risk tags with a user.", + handler=add_risk_tags, + arg_customizer=_load_risk_tag_mgmt_descriptions, + ), + Command( + u"remove-risk-tags", + u"Disassociates risk tags from a user.", + handler=remove_risk_tags, + arg_customizer=_load_risk_tag_mgmt_descriptions, + ), + ] + ) + return cmd_list def _create_handlers(): @@ -20,6 +40,17 @@ def _create_handlers(): ) +def add_risk_tags(sdk, profile, username, risk_tag): + risk_tag = _handle_list_args(risk_tag) + user_id = get_user_id(sdk, username) + sdk.detectionlists.add_user_risk_tags(user_id, risk_tag) + +def remove_risk_tags(sdk, profile, username, risk_tag): + risk_tag = _handle_list_args(risk_tag) + user_id = get_user_id(sdk, username) + sdk.detectionlists.remove_user_risk_tags(user_id, risk_tag) + + def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=None, notes=None): """Adds an employee to the high risk employee detection list. @@ -31,9 +62,7 @@ def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=No risk_tag (iter[str]): Risk tags associated with the employee. notes: (str): Notes about the employee. """ - if risk_tag and type(risk_tag) != list: - risk_tag = risk_tag.split() - + risk_tag = _handle_list_args(risk_tag) user_id = get_user_id(sdk, username) update_user(sdk, user_id, cloud_alias, risk_tag, notes) sdk.detectionlists.high_risk_employee.add(user_id) @@ -51,8 +80,7 @@ def remove_high_risk_employee(sdk, profile, username): sdk.detectionlists.high_risk_employee.remove(user_id) -def _load_add_description(argument_collection): - load_user_descriptions(argument_collection) +def _load_risk_tag_description(argument_collection): risk_tag = argument_collection.arg_configs[DetectionListUserKeys.RISK_TAG] risk_tag.as_multi_val_param() risk_tag.set_help( @@ -66,3 +94,21 @@ def _load_add_description(argument_collection): u"POOR_SECURITY_PRACTICES, " u"CONTRACT_EMPLOYEE]" ) + + +def _load_add_description(argument_collection): + load_user_descriptions(argument_collection) + _load_risk_tag_description(argument_collection) + + +def _load_risk_tag_mgmt_descriptions(argument_collection): + load_username_description(argument_collection) + _load_risk_tag_description(argument_collection) + + +def _handle_list_args(list_arg): + """Converts str args to a list. Useful for `bulk` commands which don't use `argparse` but + instead pass in values from files, such as in the form "item1 item2".""" + if list_arg and type(list_arg) != list: + return list_arg.split() + return list_arg diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index 8706e66bc..baae317d3 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -4,6 +4,8 @@ from code42cli.cmds.detectionlists.high_risk_employee import ( add_high_risk_employee, remove_high_risk_employee, + add_risk_tags, + remove_risk_tags, ) from .conftest import TEST_ID @@ -74,3 +76,57 @@ def test_remove_high_risk_employee_when_user_does_not_exist_prints_error( except UserDoesNotExistError: capture = capsys.readouterr() assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out + + +def test_add_risk_tags_adds_tags(sdk_with_user, profile): + add_risk_tags(sdk_with_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( + TEST_ID, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + ) + + +def test_add_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): + add_risk_tags(sdk_with_user, profile, _EMPLOYEE, "TAG_YOU_ARE_IT GROUND_IS_LAVA") + sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( + TEST_ID, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + ) + + +def test_add_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): + with pytest.raises(UserDoesNotExistError): + add_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + + +def test_add_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, capsys): + try: + add_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + except UserDoesNotExistError: + capture = capsys.readouterr() + assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out + + +def test_remove_risk_tags_adds_tags(sdk_with_user, profile): + remove_risk_tags(sdk_with_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( + TEST_ID, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + ) + + +def test_remove_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): + remove_risk_tags(sdk_with_user, profile, _EMPLOYEE, "TAG_YOU_ARE_IT GROUND_IS_LAVA") + sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( + TEST_ID, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + ) + + +def test_remove_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): + with pytest.raises(UserDoesNotExistError): + remove_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + + +def test_remove_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, capsys): + try: + remove_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + except UserDoesNotExistError: + capture = capsys.readouterr() + assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out From 38067f255779fd1325fb1dd036f9572c9fc1b4c0 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 24 Apr 2020 18:22:56 +0000 Subject: [PATCH 038/349] Release date (#44) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b7cf1b1..0eaff4a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## 0.5.0 - Unreleased +## 0.5.0 - 2020-04-24 ### Changed From 0624a0948c87a27f95f1d1483f53b9baaa5e9fc7 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Mon, 27 Apr 2020 11:29:23 -0500 Subject: [PATCH 039/349] fix dependencies (#45) --- CHANGELOG.md | 8 ++++++++ setup.py | 4 ++-- src/code42cli/__version__.py | 2 +- src/code42cli/cmds/detectionlists/departing_employee.py | 2 +- src/code42cli/cmds/detectionlists/high_risk_employee.py | 3 ++- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eaff4a8e..79ebe4bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 0.5.1 - 2020-04-27 + +### Fixed + +- Issue that prevented version 0.5.0 from updating its dependencies properly. + +- Issue that prevented the `add` and `bulk add` functionality of `departing-employee` and `high-risk-employee` from successfully adding users to lists when specifying optional fields. + ## 0.5.0 - 2020-04-24 ### Changed diff --git a/setup.py b/setup.py index 8a61aed10..8e68b342f 100644 --- a/setup.py +++ b/setup.py @@ -21,10 +21,10 @@ package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ - "c42eventextractor", + "c42eventextractor==0.2.7", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42", + "py42>=1.0.0", ], license="MIT", include_package_data=True, diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 3d187266f..dd9b22ccc 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/src/code42cli/cmds/detectionlists/departing_employee.py b/src/code42cli/cmds/detectionlists/departing_employee.py index 19d73ef1a..c5c6dbf8a 100644 --- a/src/code42cli/cmds/detectionlists/departing_employee.py +++ b/src/code42cli/cmds/detectionlists/departing_employee.py @@ -33,8 +33,8 @@ def add_departing_employee( notes: (str): Notes about the employee. """ user_id = get_user_id(sdk, username) - update_user(sdk, user_id, cloud_alias, notes=notes) sdk.detectionlists.departing_employee.add(user_id, departure_date) + update_user(sdk, user_id, cloud_alias, notes=notes) def remove_departing_employee(sdk, profile, username): diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index 2bd6d0d01..c5fe1211b 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -45,6 +45,7 @@ def add_risk_tags(sdk, profile, username, risk_tag): user_id = get_user_id(sdk, username) sdk.detectionlists.add_user_risk_tags(user_id, risk_tag) + def remove_risk_tags(sdk, profile, username, risk_tag): risk_tag = _handle_list_args(risk_tag) user_id = get_user_id(sdk, username) @@ -64,8 +65,8 @@ def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=No """ risk_tag = _handle_list_args(risk_tag) user_id = get_user_id(sdk, username) - update_user(sdk, user_id, cloud_alias, risk_tag, notes) sdk.detectionlists.high_risk_employee.add(user_id) + update_user(sdk, user_id, cloud_alias, risk_tag, notes) def remove_high_risk_employee(sdk, profile, username): From dc0b39446576cf4cb6e9e74e201b59749e9df986 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Wed, 29 Apr 2020 13:32:52 -0500 Subject: [PATCH 040/349] only trigger build gh action on prs (#46) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 907b24f47..1057266a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,6 @@ name: build -on: [push, pull_request] +on: [pull_request] jobs: build: From 2ce951888ac2dc7994bea6e0991b79df6360a8c6 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 29 Apr 2020 15:48:19 -0500 Subject: [PATCH 041/349] fix the bug (#47) * fix the bug * update changelog * version bump * fix test * fix test for real * add test for OrderedDicts --- CHANGELOG.md | 6 ++++++ src/code42cli/__version__.py | 2 +- src/code42cli/bulk.py | 2 +- tests/test_bulk.py | 23 +++++++++++++++++++++++ tests/test_invoker.py | 7 ++++++- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79ebe4bf0..38057f9f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 0.5.2 - 2020-04-29 + +### Fixed + +- Issue that prevented bulk csv loading. + ## 0.5.1 - 2020-04-27 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index dd9b22ccc..722515271 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.5.1" +__version__ = "0.5.2" diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 4bbbe619c..9b818ae0d 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -85,7 +85,7 @@ def run(self): self._print_result() def _process_row(self, row): - if type(row) is dict: + if isinstance(row, dict): self._process_csv_row(row) elif row: self._process_flat_file_row(row.strip()) diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 318765353..1ae612340 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from io import IOBase import pytest @@ -93,6 +94,28 @@ def test_run_bulk_process_when_not_given_reader_uses_csv_reader(bulk_processor_f class TestBulkProcessor(object): + def test_run_when_reader_returns_ordered_dict_process_kwargs(self, mock_open): + errors.ERRORED = False + processed_rows = [] + + def func_for_bulk(test1, test2): + processed_rows.append((test1, test2)) + + class MockDictReader(object): + def __call__(self, *args, **kwargs): + return [ + OrderedDict({"test1": 1, "test2": 2}), + OrderedDict({"test1": 3, "test2": 4}), + OrderedDict({"test1": 5, "test2": 6}), + ] + + processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) + processor.run() + assert (1, 2) in processed_rows + assert (3, 4) in processed_rows + assert (5, 6) in processed_rows + + def test_run_when_reader_returns_dict_process_kwargs(self, mock_open): errors.ERRORED = False processed_rows = [] diff --git a/tests/test_invoker.py b/tests/test_invoker.py index f04a5e27d..bd2dc9945 100644 --- a/tests/test_invoker.py +++ b/tests/test_invoker.py @@ -1,5 +1,8 @@ import pytest +from requests.exceptions import HTTPError +from requests import Response + from py42.exceptions import Py42ForbiddenError from code42cli import PRODUCT_NAME @@ -74,8 +77,10 @@ def test_run_when_errors_occur_from_handler_calls_log_error(self, mocker, mock_p def test_run_when_forbidden_error_occurs_prints_message(self, mocker, mock_parser, capsys): mocker.patch("{}.invoker.log_error".format(PRODUCT_NAME)) + http_error = mocker.MagicMock(spec=HTTPError) + http_error.response = mocker.MagicMock(spec=Response) cmd = Command("", "top level desc", subcommand_loader=load_subcommands) - mock_parser.parse_args.side_effect = Py42ForbiddenError(Exception()) + mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser invoker = CommandInvoker(cmd, mock_parser) From 68d38e988c6b3ba942728c217564d7e6039e9dd5 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Mon, 4 May 2020 14:21:04 -0500 Subject: [PATCH 042/349] Bugfix/update py42 (#52) --- CHANGELOG.md | 6 ++++++ setup.py | 2 +- src/code42cli/__version__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38057f9f5..38fa80bf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +### 0.5.3 - 2020-05-04 + +### Fixed + +- Issue introduced in py42 v1.1.0 that prevented `high-risk-employee` and `departing-employee` commands from working properly. + ## 0.5.2 - 2020-04-29 ### Fixed diff --git a/setup.py b/setup.py index 8e68b342f..1b8ca2860 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ "c42eventextractor==0.2.7", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42>=1.0.0", + "py42>=1.1.1", ], license="MIT", include_package_data=True, diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 722515271..43a1e95ba 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.5.2" +__version__ = "0.5.3" From cdb7fb2517cc8ac9f03b630baa8b7c596376cae3 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 6 May 2020 14:41:09 -0500 Subject: [PATCH 043/349] Chore/logging improvements real (#49) --- CHANGELOG.md | 9 + setup.py | 2 +- src/code42cli/bulk.py | 11 +- src/code42cli/cmds/detectionlists/__init__.py | 6 +- src/code42cli/cmds/profile.py | 44 +- src/code42cli/cmds/securitydata/extraction.py | 46 +- .../cmds/securitydata/logger_factory.py | 27 +- src/code42cli/config.py | 25 +- src/code42cli/errors.py | 32 - src/code42cli/invoker.py | 32 +- src/code42cli/logger.py | 184 ++++- src/code42cli/password.py | 2 - src/code42cli/profile.py | 50 +- src/code42cli/sdk_client.py | 5 +- src/code42cli/util.py | 38 - src/code42cli/worker.py | 24 +- test.txt | 741 ------------------ .../detectionlists/test_departing_employee.py | 25 +- .../detectionlists/test_high_risk_employee.py | 53 +- tests/cmds/detectionlists/test_init.py | 14 +- tests/cmds/securitydata/test_cursor_store.py | 6 +- tests/cmds/securitydata/test_extraction.py | 92 +-- tests/cmds/test_profile.py | 77 +- tests/conftest.py | 10 + tests/test_bulk.py | 82 +- tests/test_config.py | 17 +- tests/test_invoker.py | 73 +- tests/test_logger.py | 87 +- tests/test_parser.py | 2 +- tests/test_profile.py | 21 +- 30 files changed, 687 insertions(+), 1150 deletions(-) delete mode 100644 test.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 38fa80bf5..7c5e870a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- Success messages for `profile delete` and `profile update`. +- Additional information in the error log file: + - The full command path for the command that errored. + - User-facing error messages you see during adhoc sessions. + ### 0.5.3 - 2020-05-04 ### Fixed diff --git a/setup.py b/setup.py index 1b8ca2860..600fe9ed2 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ - "c42eventextractor==0.2.7", + "c42eventextractor==0.2.9", "keyring==18.0.1", "keyrings.alt==3.2.0", "py42>=1.1.1", diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 9b818ae0d..7f7abf0d8 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -4,7 +4,7 @@ from code42cli.compat import open, str from code42cli.worker import Worker -from code42cli.errors import print_errors_occurred +from code42cli.logger import get_main_cli_logger from code42cli.args import SDK_ARG_NAME, PROFILE_ARG_NAME @@ -21,7 +21,7 @@ def generate_template(handler, path=None): ] if len(args) <= 1: - print( + get_main_cli_logger().print_info( u"A blank file was generated because there are no csv headers needed for this command. " u"Simply enter one {} per line.".format(args[0]) ) @@ -103,9 +103,12 @@ def _process_flat_file_row(self, row): def _print_result(self): stats = self.__worker.stats successes = stats.total - stats.total_errors - print(u"{} processed successfully out of {}.".format(successes, stats.total)) + logger = get_main_cli_logger() + logger.print_and_log_info( + u"{} processed successfully out of {}.".format(successes, stats.total) + ) if stats.total_errors: - print_errors_occurred() + logger.print_errors_occurred_message() class CSVReader(object): diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index c9d1b5db9..0d55f953d 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,6 +1,7 @@ +from code42cli.compat import str from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory from code42cli.bulk import generate_template, run_bulk_process, CSVReader, FlatFileReader -from code42cli.util import print_error +from code42cli.logger import get_main_cli_logger from code42cli.cmds.detectionlists.enums import ( BulkCommandType, DetectionLists, @@ -148,7 +149,6 @@ def _remove_employee(self, sdk, profile, *args, **kwargs): self.handlers.remove_employee(sdk, profile, *args, **kwargs) - def load_username_description(argument_collection): """Loads the arg descriptions for the `username` CLI parameter.""" username = argument_collection.arg_configs[DetectionListUserKeys.USERNAME] @@ -184,7 +184,7 @@ def get_user_id(sdk, username): users = sdk.users.get_by_username(username)[u"users"] if not users: ex = UserDoesNotExistError(username) - print_error(str(ex)) + get_main_cli_logger().print_and_log_error(str(ex)) raise ex return users[0][u"userUid"] diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index b0f6c5b06..3e82ef722 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -1,12 +1,13 @@ -from __future__ import print_function - from getpass import getpass import code42cli.profile as cliprofile +from code42cli.compat import str +from code42cli.profile import print_and_log_no_existing_profile from code42cli.args import PROFILE_HELP, PROFILE_ARG_NAME from code42cli.commands import Command from code42cli.sdk_client import validate_connection -from code42cli.util import does_user_agree, print_error, print_no_existing_profile_message +from code42cli.util import does_user_agree +from code42cli.logger import get_main_cli_logger def load_subcommands(): @@ -79,25 +80,27 @@ def load_subcommands(): def show_profile(name=None): """Prints the given profile to stdout.""" c42profile = cliprofile.get_profile(name) - print(u"\n{0}:".format(c42profile.name)) - print(u"\t* username = {}".format(c42profile.username)) - print(u"\t* authority url = {}".format(c42profile.authority_url)) - print(u"\t* ignore-ssl-errors = {}".format(c42profile.ignore_ssl_errors)) + logger = get_main_cli_logger() + logger.print_info(u"\n{0}:".format(c42profile.name)) + logger.print_info(u"\t* username = {}".format(c42profile.username)) + logger.print_info(u"\t* authority url = {}".format(c42profile.authority_url)) + logger.print_info(u"\t* ignore-ssl-errors = {}".format(c42profile.ignore_ssl_errors)) if cliprofile.get_stored_password(c42profile.name) is not None: - print(u"\t* A password is set.") - print(u"") + logger.print_info(u"\t* A password is set.") + logger.print_info(u"") def create_profile(profile, server, username, disable_ssl_errors=False): cliprofile.create_profile(profile, server, username, disable_ssl_errors) _prompt_for_allow_password_set(profile) + get_main_cli_logger().print_info(u"Successfully created profile '{}'.".format(profile)) def update_profile(name=None, server=None, username=None, disable_ssl_errors=None): profile = cliprofile.get_profile(name) cliprofile.update_profile(profile.name, server, username, disable_ssl_errors) _prompt_for_allow_password_set(profile.name) - print(u"Profile '{}' has been updated.".format(profile.name)) + get_main_cli_logger().print_info(u"Profile '{}' has been updated.".format(profile.name)) def prompt_for_password_reset(name=None): @@ -110,7 +113,8 @@ def prompt_for_password_reset(name=None): def _validate_connection(authority, username, password): if not validate_connection(authority, username, password): - print_error( + logger = get_main_cli_logger() + logger.print_and_log_error( u"Your credentials failed to validate, so your password was not stored." u"Check your network connection and the spelling of your username and server URL." ) @@ -120,11 +124,12 @@ def _validate_connection(authority, username, password): def list_profiles(*args): """Lists all profiles that exist for this OS user.""" profiles = cliprofile.get_all_profiles() + logger = get_main_cli_logger() if not profiles: - print_no_existing_profile_message() + print_and_log_no_existing_profile() return for profile in profiles: - print(profile) + logger.print_info(str(profile)) def use_profile(profile): @@ -133,10 +138,12 @@ def use_profile(profile): def delete_profile(name): + logger = get_main_cli_logger() if cliprofile.is_default_profile(name): - print(u"\n{} is currently the default profile!".format(name)) + logger.print_info(u"\n{} is currently the default profile!".format(name)) if not does_user_agree( - u"\nDeleting this profile will also delete any stored passwords and checkpoints. Are you sure? (y/n): " + u"\nDeleting this profile will also delete any stored passwords and checkpoints. " + u"Are you sure? (y/n): " ): return cliprofile.delete_profile(name) @@ -144,17 +151,18 @@ def delete_profile(name): def delete_all_profiles(): existing_profiles = cliprofile.get_all_profiles() + logger = get_main_cli_logger() if existing_profiles: - print(u"\nAre you sure you want to delete the following profiles?") + logger.print_info(u"\nAre you sure you want to delete the following profiles?") for profile in existing_profiles: - print(u"\t{}".format(profile.name)) + logger.print_info(u"\t{}".format(profile.name)) if does_user_agree( u"\nThis will also delete any stored passwords and checkpoints. (y/n): " ): for profile in existing_profiles: cliprofile.delete_profile(profile.name) else: - print(u"\nNo profiles exist. Nothing to delete.") + logger.print_info(u"\nNo profiles exist. Nothing to delete.") def _load_optional_profile_description(argument_collection): diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index 24bf5db4b..422205e65 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -1,8 +1,6 @@ -from __future__ import print_function - import json -from c42eventextractor import FileEventHandlers +from c42eventextractor import ExtractionHandlers from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.filters import * @@ -12,11 +10,10 @@ IS_INCREMENTAL_KEY, SearchArguments, ) -from code42cli.logger import get_error_logger from code42cli.cmds.shared.cursor_store import FileEventCursorStore from code42cli.compat import str -from code42cli.util import is_interactive, print_bold, print_error, print_to_stderr import code42cli.errors as errors +from code42cli.logger import get_main_cli_logger _TOTAL_EVENTS = 0 @@ -62,7 +59,10 @@ def _determine_if_advanced_query(args): for key in given_args: val = given_args[key] if not _verify_compatibility_with_advanced_query(key, val): - print_error(u"You cannot use --advanced-query with additional search args.") + logger = get_main_cli_logger() + logger.print_and_log_error( + u"You cannot use --advanced-query with additional search args." + ) exit(1) return True return False @@ -70,10 +70,9 @@ def _determine_if_advanced_query(args): def _verify_begin_date_requirements(args, cursor_store): if _begin_date_is_required(args, cursor_store) and not args.begin: - print_error(u"'begin date' is required.") - print(u"") - print_bold(u"Try using '-b' or '--begin'. Use `-h` for more info.") - print(u"") + logger = get_main_cli_logger() + logger.print_and_log_error(u"'begin date' is required.\n") + logger.print_bold(u"Try using '-b' or '--begin'. Use `-h` for more info.\n") exit(1) @@ -94,7 +93,8 @@ def _verify_exposure_types(exposure_types): options = list(ExposureTypeOptions()) for exposure_type in exposure_types: if exposure_type not in options: - print_error(u"'{0}' is not a valid exposure type.".format(exposure_type)) + logger = get_main_cli_logger() + logger.print_and_log_error(u"'{0}' is not a valid exposure type.".format(exposure_type)) exit(1) @@ -121,16 +121,16 @@ def _get_event_timestamp_filter(begin_date, end_date): end_date = end_date.strip() if end_date else None return date_helper.create_event_timestamp_filter(begin_date, end_date) except date_helper.DateArgumentException as ex: - print_error(str(ex)) + get_main_cli_logger().print_and_log_error(str(ex)) exit(1) def _create_event_handlers(output_logger, cursor_store): - handlers = FileEventHandlers() - error_logger = get_error_logger() + handlers = ExtractionHandlers() def handle_error(exception): - error_logger.error(exception) + logger = get_main_cli_logger() + logger.log_error(exception) errors.ERRORED = True handlers.handle_error = handle_error @@ -172,9 +172,17 @@ def _verify_compatibility_with_advanced_query(key, val): def _handle_result(): # Have to call this explicitly (instead of relying on invoker) because errors are caught in # `c42eventextractor`. - errors.print_errors_occurred_if_needed() + logger = get_main_cli_logger() + _print_errors_occurred_if_needed(logger) if not _TOTAL_EVENTS: - print_to_stderr(u"No results found\n") + logger.print_and_log_info(u"No results found.") + + +def _print_errors_occurred_if_needed(logger): + """If interactive and errors occurred, it will print a message telling the user how to retrieve + error logs.""" + if errors.ERRORED: + logger.print_errors_occurred_message() def _try_append_exposure_types_filter(filters, include_non_exposure_events, exposure_types): @@ -185,7 +193,9 @@ def _try_append_exposure_types_filter(filters, include_non_exposure_events, expo def _create_exposure_type_filter(include_non_exposure_events, exposure_types): if include_non_exposure_events and exposure_types: - print_error(u"Cannot use exposure types with `--include-non-exposure`.") + get_main_cli_logger().print_and_log_error( + u"Cannot use exposure types with `--include-non-exposure`." + ) exit(1) if exposure_types: return ExposureType.is_in(exposure_types) diff --git a/src/code42cli/cmds/securitydata/logger_factory.py b/src/code42cli/cmds/securitydata/logger_factory.py index a5a0b8947..0b36eab34 100644 --- a/src/code42cli/cmds/securitydata/logger_factory.py +++ b/src/code42cli/cmds/securitydata/logger_factory.py @@ -9,25 +9,23 @@ from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper from code42cli.cmds.securitydata.enums import OutputFormat -from code42cli.util import get_url_parts, print_error -from code42cli.logger import logger_has_handlers, logger_deps_lock, apply_logger_dependencies +from code42cli.util import get_url_parts +from code42cli.logger import ( + logger_has_handlers, + logger_deps_lock, + add_handler_to_logger, + get_main_cli_logger, + get_logger_for_stdout as get_stdout_logger, +) def get_logger_for_stdout(output_format): """Gets the stdout logger for the given format. - Args: output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. """ - logger = logging.getLogger(u"code42_stdout_{0}".format(output_format.lower())) - if logger_has_handlers(logger): - return logger - - with logger_deps_lock: - if not logger_has_handlers(logger): - handler = logging.StreamHandler(sys.stdout) - return _init_logger(logger, handler, output_format) - return logger + formatter = _get_formatter(output_format) + return get_stdout_logger(output_format.lower(), formatter) def get_logger_for_file(filename, output_format): @@ -69,7 +67,8 @@ def get_logger_for_server(hostname, protocol, output_format): url_parts[0], port=port, protocol=protocol ).handler except: - print_error(u"Unable to connect to {0}.".format(hostname)) + logger = get_main_cli_logger() + logger.print_and_log_error(u"Unable to connect to {0}.".format(hostname)) exit(1) return _init_logger(logger, handler, output_format) return logger @@ -78,7 +77,7 @@ def get_logger_for_server(hostname, protocol, output_format): def _init_logger(logger, handler, output_format): formatter = _get_formatter(output_format) logger.setLevel(logging.INFO) - return apply_logger_dependencies(logger, handler, formatter) + return add_handler_to_logger(logger, handler, formatter) def _get_formatter(output_format): diff --git a/src/code42cli/config.py b/src/code42cli/config.py index 5ec120827..7348a45e8 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -1,16 +1,20 @@ -from __future__ import print_function - import os from configparser import ConfigParser import code42cli.util as util from code42cli.compat import str +from code42cli.logger import get_main_cli_logger class NoConfigProfileError(Exception): - def __init__(self): - super(NoConfigProfileError, self).__init__(u"Profile does not exist.") + def __init__(self, profile_arg_name=None): + message = ( + u"Profile '{}' does not exist.".format(profile_arg_name) + if profile_arg_name + else u"Profile does not exist." + ) + super(NoConfigProfileError, self).__init__(message) class ConfigAccessor(object): @@ -37,7 +41,8 @@ def get_profile(self, name=None): """ name = name or self._default_profile_name if name not in self._get_sections() or name == self.DEFAULT_VALUE: - raise NoConfigProfileError() + name = name if name != self.DEFAULT_VALUE else None + raise NoConfigProfileError(name) return self._get_profile(name) def get_all_profiles(self): @@ -75,15 +80,17 @@ def update_profile(self, name, server=None, username=None, ignore_ssl_errors=Non def switch_default_profile(self, new_default_name): """Changes what is marked as the default profile in the internal section.""" if self.get_profile(new_default_name) is None: - raise NoConfigProfileError() + raise NoConfigProfileError(new_default_name) self._internal[self.DEFAULT_PROFILE] = new_default_name self._save() - print(u"{} has been set as the default profile.".format(new_default_name)) + get_main_cli_logger().print_info( + u"{} has been set as the default profile.".format(new_default_name) + ) def delete_profile(self, name): """Deletes a profile.""" if self.get_profile(name) is None: - raise NoConfigProfileError() + raise NoConfigProfileError(name) self.parser.remove_section(name) if name == self._default_profile_name: self._internal[self.DEFAULT_PROFILE] = self.DEFAULT_VALUE @@ -143,7 +150,7 @@ def _try_complete_setup(self, profile): return self._save() - print(u"Successfully saved profile '{}'.".format(profile.name)) + get_main_cli_logger().print_info(u"Successfully saved profile '{}'.".format(profile.name)) default_profile = self._internal.get(self.DEFAULT_PROFILE) if default_profile is None or default_profile == self.DEFAULT_VALUE: diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index fc14d6aa4..112a3bbbb 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -1,33 +1 @@ -from code42cli.logger import get_error_logger, ERROR_LOG_FILE_NAME -from code42cli.util import is_interactive, print_error, get_user_project_path - - ERRORED = False - - -def log_error(exception): - """Logs the error to the CLI error log file. If running interactively, it will also print a - message telling the user the location of the error log file.""" - logger = get_error_logger() - logger.error(exception) - global ERRORED - ERRORED = True - print_errors_occurred_if_needed() - - -def print_errors_occurred_if_needed(): - """If interactive and errors occurred, it will print a message telling the user how to retrieve - error logs.""" - if is_interactive() and ERRORED: - print_errors_occurred() - - -def print_errors_occurred(): - """Prints a message telling the user how to retrieve error logs.""" - print_error(get_error_message()) - - -def get_error_message(): - """Returns the error message that is printed when errors occur.""" - path = get_user_project_path(u"log") - return u"View exceptions that occurred at {}/{}.".format(path, ERROR_LOG_FILE_NAME) diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py index d3460fc37..a727afdea 100644 --- a/src/code42cli/invoker.py +++ b/src/code42cli/invoker.py @@ -1,12 +1,9 @@ -from __future__ import print_function - import sys -from py42.exceptions import Py42ForbiddenError +from py42.exceptions import Py42HTTPError, Py42ForbiddenError from code42cli.parser import ArgumentParserError, CommandParser -from code42cli.errors import log_error -from code42cli.util import print_error +from code42cli.logger import get_main_cli_logger class CommandInvoker(object): @@ -23,18 +20,24 @@ def run(self, input_args): input_args (iter[str]): the full list of arguments supplied by the user to `code42` cli command. """ + invocation_str = u"code42 {}".format(u" ".join(input_args)) try: path_parts = self._get_path_parts(input_args) command = self._commands.get(u" ".join(path_parts)) self._try_run_command(command, path_parts, input_args) except Py42ForbiddenError as err: - log_error(err) - print_error( - u"You do not have the necessary permissions to perform this task. " - u"Try using or creating a different profile." - ) - except Exception as ex: - log_error(ex) + logger = get_main_cli_logger() + logger.log_verbose_error(invocation_str, err.response.request) + logger.print_and_log_permissions_error() + logger.print_errors_occurred_message() + except Py42HTTPError as err: + logger = get_main_cli_logger() + logger.log_verbose_error(invocation_str, err.response.request) + logger.print_errors_occurred_message() + except Exception: + logger = get_main_cli_logger() + logger.log_verbose_error(invocation_str) + logger.print_errors_occurred_message() def _get_path_parts(self, input_args): """Gets the portion of `input_args` that refers to a @@ -68,6 +71,7 @@ def _load_subcommands(self, path, node): def _try_run_command(self, command, path_parts, input_args): """Runs a command called using `path_parts` by parsing `input_args` and calling the command's handler.""" + parser = None try: if not path_parts: parser = self._cmd_parser.prepare_cli_help(command) @@ -75,7 +79,7 @@ def _try_run_command(self, command, path_parts, input_args): parser = self._cmd_parser.prepare_command(command, path_parts) parsed_args = self._cmd_parser.parse_args(input_args) parsed_args.func(parsed_args) - except ArgumentParserError as e: - print(u"error: {}".format(e), file=sys.stderr) + except ArgumentParserError as err: + get_main_cli_logger().log_error(err) parser.print_help(sys.stderr) sys.exit(2) diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 5c593c8d1..3982bb628 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -1,28 +1,50 @@ -import logging +import logging, sys, traceback from logging.handlers import RotatingFileHandler from threading import Lock from code42cli.compat import str -from code42cli.util import get_user_project_path, print_error +from code42cli.util import get_user_project_path, is_interactive logger_deps_lock = Lock() ERROR_LOG_FILE_NAME = u"code42_errors.log" +_PERMISSIONS_MESSAGE = ( + u"You do not have the necessary permissions to perform this task. " + + u"Try using or creating a different profile." +) -def get_error_logger(): - """Gets the logger where exceptions are logged.""" - log_path = get_user_project_path(u"log") - log_path = u"{}/{}".format(log_path, ERROR_LOG_FILE_NAME) - logger = logging.getLogger(u"code42_error_logger") +def get_logger_for_stdout(name_suffix=u"main", formatter=None): + logger = logging.getLogger(u"code42_stdout_{}".format(name_suffix)) if logger_has_handlers(logger): return logger with logger_deps_lock: if not logger_has_handlers(logger): - formatter = logging.Formatter(u"%(asctime)s %(message)s") - handler = RotatingFileHandler(log_path, maxBytes=250000000, encoding=u"utf-8") - return apply_logger_dependencies(logger, handler, formatter) + handler = logging.StreamHandler(sys.stdout) + formatter = formatter or _get_standard_formatter() + logger.setLevel(logging.INFO) + return add_handler_to_logger(logger, handler, formatter) + return logger + + +def _get_standard_formatter(): + return logging.Formatter(u"%(message)s") + + +def _get_error_log_path(): + log_path = get_user_project_path(u"log") + return u"{}/{}".format(log_path, ERROR_LOG_FILE_NAME) + + +def _create_error_file_handler(): + log_path = _get_error_log_path() + return RotatingFileHandler(log_path, maxBytes=250000000, encoding=u"utf-8") + + +def add_handler_to_logger(logger, handler, formatter): + handler.setFormatter(formatter) + logger.addHandler(handler) return logger @@ -30,11 +52,139 @@ def logger_has_handlers(logger): return len(logger.handlers) -def apply_logger_dependencies(logger, handler, formatter): - try: - handler.setFormatter(formatter) - logger.addHandler(handler) - except Exception as ex: - print_error(str(ex)) - exit(1) +def _get_error_file_logger(): + """Gets the logger where raw exceptions are logged.""" + logger = logging.getLogger(u"code42_error_logger") + if logger_has_handlers(logger): + return logger + + with logger_deps_lock: + if not logger_has_handlers(logger): + formatter = _create_formatter_for_error_file() + handler = _create_error_file_handler() + return add_handler_to_logger(logger, handler, formatter) return logger + + +def get_view_exceptions_location_message(): + """Returns the error message that is printed when errors occur.""" + path = _get_error_log_path() + return u"View exceptions that occurred at {}.".format(path) + + +def _get_user_error_logger(): + if is_interactive(): + return _get_interactive_user_error_logger() + else: + return _get_error_file_logger() + + +def _get_interactive_user_error_logger(): + """This logger has two handlers, one for stderr and one for the error log file.""" + logger = logging.getLogger(u"code42_stderr_main") + if logger_has_handlers(logger): + return logger + + with logger_deps_lock: + if not logger_has_handlers(logger): + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_formatter = _get_standard_formatter() + stderr_handler.setFormatter(stderr_formatter) + + file_handler = _create_error_file_handler() + file_formatter = _create_formatter_for_error_file() + file_handler.setFormatter(file_formatter) + + add_handler_to_logger(logger, stderr_handler, stderr_formatter) + add_handler_to_logger(logger, file_handler, file_formatter) + + logger.setLevel(logging.ERROR) + return logger + return logger + + +def _create_formatter_for_error_file(): + return logging.Formatter(u"%(asctime)s %(message)s") + + +def _get_red_error_text(text): + return u"\033[91mERROR: {}\033[0m".format(text) + + +class CliLogger(object): + """There are three loggers part of the CliLogger. The following table illustrates where they + log to in both interactive mode and non-interactive mode. + """ + + def __init__(self): + """The following properties explain how to log to different locations: + + `self._info_logger` is for when you want to display simple information, like + `profile list`. This does _not_ go to the log file. + + `self._user_error_logger` is for when you want to print in red text to the user. It also + goes to the log file for debugging purposes. + + `self._error_file_logger` logs directly to the error file and is only meant for verbose + debugging information, such as raw exceptions. + """ + self._info_logger = get_logger_for_stdout() + self._user_error_logger = _get_user_error_logger() + self._error_file_logger = _get_error_file_logger() + + def print_info(self, message): + self._info_logger.info(message) + + def print_bold(self, message): + self._info_logger.info(u"\033[1m{}\033[0m".format(message)) + + def print_and_log_error(self, message): + """For not interrupting stdout output. Excludes red text and 'ERROR: ' from `error()`. + """ + """Logs red text to stderr and a log file.""" + self._user_error_logger.error(_get_red_error_text(message)) + + def print_and_log_info(self, message): + """Logs red text to stderr and a log file.""" + self._user_error_logger.error(message) + + def log_error(self, err): + if err: + message = str(err) # Filter out empty string logs. + if message: + self._error_file_logger.error(message) + + def print_errors_occurred_message(self, additional_info=None): + """Prints a message telling the user how to retrieve error logs.""" + locations_message = get_view_exceptions_location_message() + message = ( + u"{}\n{}".format(additional_info, locations_message) + if additional_info + else locations_message + ) + # Use `info()` because this message is pointless in the error log. + self.print_info(_get_red_error_text(message)) + + def log_verbose_error(self, invocation_str=None, http_request=None): + """For logging traces, invocation strs, and request parameters during exceptions to the + error log file.""" + prefix = ( + u"Exception occurred." + if not invocation_str + else "Exception occurred from input: '{}'.".format(invocation_str) + ) + message = u"{}. See error below.".format(prefix) + self.log_error(message) + self.log_error(traceback.format_exc()) + if http_request: + self.log_error(u"Request parameters: {}".format(http_request.body)) + + def print_and_log_permissions_error(self): + self.print_and_log_error(_PERMISSIONS_MESSAGE) + + def log_permissions_error(self): + self.log_error(_PERMISSIONS_MESSAGE) + + +def get_main_cli_logger(): + return CliLogger() diff --git a/src/code42cli/password.py b/src/code42cli/password.py index d57d86be7..c44545af2 100644 --- a/src/code42cli/password.py +++ b/src/code42cli/password.py @@ -1,5 +1,3 @@ -from __future__ import print_function - from getpass import getpass import keyring diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index 2f6807508..acec8903b 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -1,12 +1,8 @@ +from code42cli.compat import str import code42cli.password as password from code42cli.cmds.shared.cursor_store import get_file_event_cursor_store from code42cli.config import ConfigAccessor, config_accessor, NoConfigProfileError -from code42cli.util import ( - print_error, - print_create_profile_help, - print_set_default_profile_help, - print_no_existing_profile_message, -) +from code42cli.logger import get_main_cli_logger class Code42Profile(object): @@ -58,8 +54,9 @@ def get_profile(profile_name=None): try: return _get_profile(profile_name) except NoConfigProfileError as ex: - print_error(str(ex)) - print_create_profile_help() + logger = get_main_cli_logger() + logger.print_and_log_error(str(ex)) + _print_create_profile_help() exit(1) @@ -81,9 +78,9 @@ def validate_default_profile(): if not default_profile_exists(): existing_profiles = get_all_profiles() if not existing_profiles: - print_no_existing_profile_message() + print_and_log_no_existing_profile() else: - print_set_default_profile_help(existing_profiles) + _print_set_default_profile_help(existing_profiles) exit(1) @@ -96,12 +93,14 @@ def profile_exists(profile_name=None): def switch_default_profile(profile_name): - config_accessor.switch_default_profile(profile_name) + profile = get_profile(profile_name) # Handles if profile does not exist. + config_accessor.switch_default_profile(profile.name) def create_profile(name, server, username, ignore_ssl_errors): if profile_exists(name): - print_error(u"A profile named {} already exists.".format(name)) + logger = get_main_cli_logger() + logger.print_and_log_error(u"A profile named '{}' already exists.".format(name)) exit(1) config_accessor.create_profile(name, server, username, ignore_ssl_errors) @@ -114,6 +113,7 @@ def delete_profile(profile_name): cursor_store = get_file_event_cursor_store(profile_name) cursor_store.clean() config_accessor.delete_profile(profile_name) + get_main_cli_logger().print_info(u"Profile '{}' has been deleted.".format(profile_name)) def update_profile(name, server, username, ignore_ssl_errors): @@ -133,3 +133,29 @@ def get_stored_password(profile_name=None): def set_password(new_password, profile_name=None): profile = get_profile(profile_name) password.set_password(profile, new_password) + + +def print_and_log_no_existing_profile(): + logger = get_main_cli_logger() + logger.print_and_log_error(u"No existing profile.") + _print_create_profile_help() + + +def _print_create_profile_help(): + logger = get_main_cli_logger() + logger.print_info(u"\nTo add a profile, use: ") + logger.print_bold(u"\tcode42 profile create \n") + + +def _print_set_default_profile_help(existing_profiles): + logger = get_main_cli_logger() + logger.print_info( + u"\nNo default profile set.\n" + u"\nUse the --profile flag to specify which profile to use.\n" + u"\nTo set the default profile (used whenever --profile argument is not provided), use:" + ) + logger.print_bold(u"\tcode42 profile use ") + logger.print_info(u"\nExisting profiles:") + for profile in existing_profiles: + logger.print_info("\t{}".format(profile)) + logger.print_info(u"") diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index a4693b65f..b1ccaf14f 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -1,7 +1,7 @@ import py42.sdk import py42.settings.debug as debug -from code42cli.util import print_error +from code42cli.logger import get_main_cli_logger def create_sdk(profile, is_debug_mode): @@ -11,7 +11,8 @@ def create_sdk(profile, is_debug_mode): password = profile.get_password() return py42.sdk.from_local_account(profile.authority_url, profile.username, password) except Exception: - print_error( + logger = get_main_cli_logger() + logger.print_and_log_error( u"Invalid credentials or host address. " u"Verify your profile is set up correctly and that you are supplying the correct password." ) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 9fb1be769..738326c99 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,5 +1,3 @@ -from __future__ import print_function, with_statement - import sys from os import makedirs, path @@ -39,49 +37,13 @@ def open_file(file_path, mode, action): return action(f) -def print_error(error_text): - """Prints red text.""" - print(u"\033[91mERROR: {}\033[0m".format(error_text)) - - -def print_bold(bold_text): - print(u"\033[1m{}\033[0m".format(bold_text)) - - def is_interactive(): return sys.stdin.isatty() -def print_no_existing_profile_message(): - print_error(u"No existing profile.") - print_create_profile_help() - - -def print_create_profile_help(): - print(u"\nTo add a profile, use: ") - print_bold(u"\tcode42 profile create \n") - - -def print_set_default_profile_help(existing_profiles): - print( - u"\nNo default profile set.\n", - u"\nUse the --profile flag to specify which profile to use.\n", - u"\nTo set the default profile (used whenever --profile argument is not provided), use:", - ) - print_bold(u"\tcode42 profile use ") - print(u"\nExisting profiles:") - for profile in existing_profiles: - print("\t{}".format(profile)) - print(u"") - - def get_url_parts(url_str): parts = url_str.split(u":") port = None if len(parts) > 1 and parts[1] != u"": port = int(parts[1]) return parts[0], port - - -def print_to_stderr(error_text): - sys.stderr.write(error_text) diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 97db2866f..8071fe985 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -1,7 +1,9 @@ from threading import Thread, Lock +from py42.exceptions import Py42HTTPError, Py42ForbiddenError + from code42cli.compat import queue -from code42cli.logger import get_error_logger +from code42cli.logger import get_main_cli_logger class WorkerStats(object): @@ -37,7 +39,6 @@ class Worker(object): def __init__(self, thread_count): self._queue = queue.Queue() self._thread_count = thread_count - self._error_logger = get_error_logger() self._stats = WorkerStats() self.__started = False self.__start_lock = Lock() @@ -76,9 +77,19 @@ def _process_queue(self): args = task[u"args"] kwargs = task[u"kwargs"] func(*args, **kwargs) - except Exception as ex: - self._error_logger.error(ex) - self._stats.increment_total_errors() + except Py42ForbiddenError as err: + self._increment_total_errors() + logger = get_main_cli_logger() + logger.log_verbose_error(http_request=err.response.request) + logger.log_permissions_error() + except Py42HTTPError as err: + self._increment_total_errors() + logger = get_main_cli_logger() + logger.log_verbose_error(http_request=err.response.request) + except Exception: + self._increment_total_errors() + logger = get_main_cli_logger() + logger.log_verbose_error() finally: self._stats.increment_total() self._queue.task_done() @@ -88,3 +99,6 @@ def __start(self): t = Thread(target=self._process_queue) t.daemon = True t.start() + + def _increment_total_errors(self): + self._stats.increment_total_errors() diff --git a/test.txt b/test.txt deleted file mode 100644 index 8631cfdae..000000000 --- a/test.txt +++ /dev/null @@ -1,741 +0,0 @@ -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_266", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:08.054Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunJenkinsSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 503, "fileOwner": "kathy.kane", "md5Checksum": "31e9b26ca9caeafd44b1d81d7fd216c3", "sha256Checksum": "e0de2ec27a9bb5ba229cd38c47d3015ab20345a6a92a0b2e3e8276c2e104bfa7", "createTimestamp": "2020-04-01T11:49:19.390Z", "modifyTimestamp": "2020-04-01T11:49:21.102Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_274", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:27.325Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver.exe", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Executable", "fileSize": 8543232, "fileOwner": "kathy.kane", "md5Checksum": "8ee62a8925030966a240521561e13f5a", "sha256Checksum": "66cfa645f83fde41720beac7061a559fd57b6f5caa83d7918f44de0f4dd27845", "createTimestamp": "2020-04-01T11:47:08.616Z", "modifyTimestamp": "2020-04-01T11:47:11.721Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-msdownload", "mimeTypeByExtension": "application/x-dosexec", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_269", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:07.038Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunSingleSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 490, "fileOwner": "kathy.kane", "md5Checksum": "075169d962d428547131e8669343b64b", "sha256Checksum": "336372de237f7f355550fdf8e48294c24a931f57a244176f58379e14f78d6f01", "createTimestamp": "2020-04-01T11:49:24.618Z", "modifyTimestamp": "2020-04-01T11:49:26.509Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_272", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:23.245Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Uncategorized", "fileSize": 14713200, "fileOwner": "kathy.kane", "md5Checksum": "f8999bb031325631ec685aba3c3266f5", "sha256Checksum": "b91856fda0fc769d8781dac5592b3f776f16b45b82b23fd636d45646e7d5d1f5", "createTimestamp": "2020-04-01T11:47:22.050Z", "modifyTimestamp": "2020-04-01T11:47:23.711Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-mach-o", "mimeTypeByExtension": "application/octet-stream", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_12", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.019Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.328Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_10", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.298Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_13", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.057Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.332Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_11", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.983Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.324Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_410", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.752Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToDecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1137, "fileOwner": "kathy.kane", "md5Checksum": "22f1e7d589972ca5fad60c8519d20e54", "sha256Checksum": "91fd221bf07accb12fb54f8a24349442a70a6f1e2a784e02d7b54c8183805613", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.765Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_411", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.736Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToHexadecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1642, "fileOwner": "kathy.kane", "md5Checksum": "985232edbb7900aa3def0a349718265e", "sha256Checksum": "0a9745b02fff401f03afbf571f11465373edaa1f63a8cb6f6503f4a5768ef9a2", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.827Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_409", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:51.298Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "IntegerToRoman.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1149, "fileOwner": "kathy.kane", "md5Checksum": "c10dce754394e1d1af170a9be3fef3f4", "sha256Checksum": "0c1bdae526817ae624223a8d3231ba3e1b6e8f67708e2db7eda1150477e7414a", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.886Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_412", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:53.816Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "RomanToInteger.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1441, "fileOwner": "kathy.kane", "md5Checksum": "d92e8215a4b799c8f9a2dec10218ab01", "sha256Checksum": "e2cd78b8a1a258b114648240eeeef7bdec5e68e54713174e0a01c0a7bb72a46c", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.796Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_80", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.784Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_81", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.815Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_79", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.753Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.237Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_78", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.034Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.472Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_294", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.897Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:31.116Z", "modifyTimestamp": "2019-08-12T16:41:56.988Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_292", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.554Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:31.038Z", "modifyTimestamp": "2019-08-12T16:41:57.139Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_293", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.803Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:31.069Z", "modifyTimestamp": "2019-08-12T16:41:55.779Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_291", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.366Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:31.007Z", "modifyTimestamp": "2019-08-12T16:41:56.842Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_324", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:58.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "MSA - Lackawanna Touring Company.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Document", "fileSize": 382094, "fileOwner": "kathy.kane", "md5Checksum": "39e21b6e0a1d4902c98baa5e3aeaba19", "sha256Checksum": "854156252e3ca1024050b7c20e76b3ede6649a48a3980899ef04ab9df534abc5", "createTimestamp": "2020-04-01T10:43:57.354Z", "modifyTimestamp": "2020-04-01T10:44:00.510Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_323", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.879Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "LTC - DC Replacement Project Plan.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 13635, "fileOwner": "kathy.kane", "md5Checksum": "3ef51bbb881c915bba30a6796553c005", "sha256Checksum": "4c3d8223b02f4299c80c0590dddd4c206f00b89419753fd9301b8cc992aa5fe9", "createTimestamp": "2020-04-01T10:43:36.417Z", "modifyTimestamp": "2020-04-01T10:43:39.729Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_322", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Lackawanna Touring Company.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Spreadsheet", "fileSize": 32354, "fileOwner": "kathy.kane", "md5Checksum": "aab45b5dd52dccb21a0e7e18bff9229e", "sha256Checksum": "90fa1ba4dfd2624c66e13ed6de7e676fb3558d2e4dd424aa2bbb5740b65b31cf", "createTimestamp": "2020-04-01T10:43:44.916Z", "modifyTimestamp": "2020-04-01T10:43:48.385Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_687", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:04.665Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "ZOOOOOOMYBoi.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 22137371, "fileOwner": "darnell.waters", "md5Checksum": "124fa909c632f80b70f016eecf440fd3", "sha256Checksum": "043173fb09f1001dcad6934dfd988b6fe91f6f03982dcc92dfe0292a93a4e803", "createTimestamp": "2020-02-06T15:42:20Z", "modifyTimestamp": "2020-02-19T19:11:17.378Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_685", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:49:09.123Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "GotWings.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 12654813, "fileOwner": "darnell.waters", "md5Checksum": "84958f28d8e3f0af82a9143fa98edc92", "sha256Checksum": "771acf81676efa85688fed2b7b0850a75cf6857d5998e9eab7c4247a3a48314e", "createTimestamp": "2020-02-06T15:25:30Z", "modifyTimestamp": "2020-02-19T19:11:18.539Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_686", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:20.332Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "THEBOSS.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 28262513, "fileOwner": "darnell.waters", "md5Checksum": "62eda4aada3ee1c7b18ab10970636b54", "sha256Checksum": "15f9d5e9ef79a3d6755b6df9b8406f3d0adf4abbab07d2b7df5645f71530554f", "createTimestamp": "2020-02-06T15:22:40Z", "modifyTimestamp": "2020-02-19T19:11:19.568Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_183", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:46:19.896Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_182", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:45:57.608Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_82", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.948Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-devportal.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1447, "fileOwner": "sean.cassidy", "md5Checksum": "0beee3cec377487154903f2d213c37fe", "sha256Checksum": "5a810a00d365c563314808e7c7934e531f327277e00ca6267976f036a170d28c", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.658Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_86", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.986Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-redis.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2100, "fileOwner": "sean.cassidy", "md5Checksum": "a340a797bd8e0981bf9dc9f3b4cd6f0c", "sha256Checksum": "f4a2b19821e2c8f096b2e74663bb0d2664046edf6b9f5c4b736b860c55ec933a", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.736Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_90", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.003Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds-rbac.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2004, "fileOwner": "sean.cassidy", "md5Checksum": "3905c4678af557eb44841c4bb2525b80", "sha256Checksum": "784d7f9cc3d709b7e1e7dbbfaa9027a887263ff78398f4ef4a5e0b43e1e64173", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.829Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_81", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.965Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "admin-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1491, "fileOwner": "sean.cassidy", "md5Checksum": "f61050ab8def08a384bbd0bed47c8cd6", "sha256Checksum": "84242f6fefff710efc16971b44479f81881ccccd3e96bc07a35c29ae99a04178", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.626Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_87", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.966Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2680, "fileOwner": "sean.cassidy", "md5Checksum": "080fc77a1284b0439dc8218df43668a9", "sha256Checksum": "5ad11226c30229686464543324255046f4d5a89c19f1fb6fda674b44f6c9fce3", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.752Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_83", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.928Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-auth.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1203, "fileOwner": "sean.cassidy", "md5Checksum": "d9b52beb12fd195f8bf347c4ea95df62", "sha256Checksum": "c2b67cb056dc7e4fa82dac3d3b18091922619c02e2783247fcb2c068987944d6", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.673Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_85", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:28.983Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-ratelimit.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 371, "fileOwner": "sean.cassidy", "md5Checksum": "ee51e7c14f3bafb58ea317d2173c1b79", "sha256Checksum": "86fec44093ad5c8aee2dd98f4686eb0e9b8fc98d8e0e5a5e4b762b47fb30c372", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.720Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_84", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.007Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-license-key-secret.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 227, "fileOwner": "sean.cassidy", "md5Checksum": "fd89e26a07fa4f8503fd40259f6d43d5", "sha256Checksum": "7395defcf955595295ba8c3ce16890fc4ee987311b2c801b1fc6a31a03053307", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.689Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_91", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:24.418Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 136, "fileOwner": "sean.cassidy", "md5Checksum": "fe3a88fb7c4f3032ddc75a50844d42fd", "sha256Checksum": "240083c41b206ada276328f0988b28a140bfd09f3b884e80463557db69d29d18", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.845Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_89", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.923Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crd-delete.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1621, "fileOwner": "sean.cassidy", "md5Checksum": "41b4d9e96a10d80087088eb06e3d92bd", "sha256Checksum": "b4fcecdc5b9a440d976e27adaa99d48d5eeacbc9a8c98827b0ce4c3a43f4cf01", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.798Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_88", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.946Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "config.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 605, "fileOwner": "sean.cassidy", "md5Checksum": "8042961777d6ee44573224233a9687ea", "sha256Checksum": "089cd64bda07824df9b16a51d9f9b2c3c3dd835624c39c6fb39bab562b65f038", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.783Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947792142030207362_434", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:24:35.336Z", "insertionTimestamp": "2020-03-31T17:29:02.459Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/", "fileName": "configure.py", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 57602, "fileOwner": "sean.cassidy", "md5Checksum": "75a4c54c9421b296c0a63a044029fad5", "sha256Checksum": "8ab6290f42c53c940f08f4fbe520ebd5e72d1dc85683b17783e38b89280f1a41", "createTimestamp": "2020-03-07T17:41:27.411Z", "modifyTimestamp": "2020-03-31T17:24:07.424Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.4.0\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-python", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947791329686485442_62", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:18:33.189Z", "insertionTimestamp": "2020-03-31T17:20:56.912Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "your-marketing-plan-template.doc", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Document", "fileSize": 45568, "fileOwner": "Administrators", "md5Checksum": "6bb8604e540d3df44f18db72dfd5908f", "sha256Checksum": "4e121eed4819e5586930844475505da498f9ce424d3d43595a0d3473bfada2fc", "createTimestamp": "2019-02-07T16:23:05.662Z", "modifyTimestamp": "2019-02-07T16:23:06.908Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (54) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-tika-msoffice", "mimeTypeByExtension": "application/msword", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947790854009780610_771", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:02:39.749Z", "insertionTimestamp": "2020-03-31T17:16:14.843Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/HashMaker/", "fileName": "BlockAllocator.h", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "SourceCode", "fileCategoryByExtension": "SourceCode", "fileSize": 3549, "fileOwner": "sean.cassidy", "md5Checksum": "601f6f6fc877d60922b9c1012370232c", "sha256Checksum": "f57cae2718ffea77ddb86fb0f95b214651626b167712ae2d0f9306259a7a6907", "createTimestamp": "2020-03-19T01:38:00Z", "modifyTimestamp": "2020-03-19T03:43:46.973Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.3.1\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/x-csrc", "mimeTypeByExtension": "text/x-chdr", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947790235711338946_143", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:08:56.530Z", "insertionTimestamp": "2020-03-31T17:10:04.773Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "The Chiropractic Report Chapman Referral Letters.PDF", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 503962, "fileOwner": "Administrators", "md5Checksum": "9c0b34317626ab2b393d48e8f726569e", "sha256Checksum": "0536562c0e47848c6dcab72cade08eefeea5a0c67cb3c0b92f79d7b585522807", "createTimestamp": "2018-10-02T19:13:47.218Z", "modifyTimestamp": "2018-03-21T21:22:48.303Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (53) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_411", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:29.955Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T16:57:23.132Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_410", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.063Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T16:57:23.141Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_409", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.028Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T16:57:23.131Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_269", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.207Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_271", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.218Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_270", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.214Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_266", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.189Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_265", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.188Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_260", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.215Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_259", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.190Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_263", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.222Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_262", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.221Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_261", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.217Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_258", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.186Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_257", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.183Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_268", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.206Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_256", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.181Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_267", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.192Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_264", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.184Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_117", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.327Z", "insertionTimestamp": "2020-03-31T11:04:15.595Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T11:00:48.869Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_115", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:51.545Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T11:00:48.353Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_116", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.389Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T11:00:48.885Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "643502901225__8749df16-e136-4268-bc85-5323f8db2597", "eventType": "MODIFIED", "eventTimestamp": "2020-03-31T03:08:06.978Z", "insertionTimestamp": "2020-03-31T09:02:25.372Z", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 56653, "fileOwner": "kathy.kane@c42se.com", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:17:38Z", "modifyTimestamp": "2020-03-30T12:17:38Z", "actor": "kathy.kane@c42se.com", "directoryId": ["108056515629"], "source": "Box", "url": "https://code42a.box.com/s/sblis4r0zr5p0rbrr87fu3zml8svej58", "shared": "TRUE", "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "9981852168", "detectionSourceAlias": "C42 SE Box", "fileId": "643502901225", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk_2_9164694a-48e8-4c89-aed8-36d51d6338d4", "eventType": "CREATED", "eventTimestamp": "2020-03-30T15:29:52.894Z", "insertionTimestamp": "2020-03-31T00:01:48.913Z", "fileName": "9.29 Meeting Notes.txt", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "Document", "fileSize": 8089, "fileOwner": "george.washington@c42se.com", "md5Checksum": "86eb5a3c9d0ea6b6c37d3f988f42c718", "sha256Checksum": "4468e007b9b4a8050c10d29b3c9b38ea66d896389b1c27c4d030e129ab0ab688", "createTimestamp": "2020-03-30T15:05:27.880Z", "modifyTimestamp": "2020-03-30T15:05:39.871Z", "actor": "george.washington@c42se.com", "directoryId": ["0AB20OqRQS81NUk9PVA"], "source": "GoogleDrive", "url": "https://drive.google.com/a/c42se.com/file/d/1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk/view?usp=drivesdk", "shared": "TRUE", "sharedWith": [{"cloudUsername": "External (Public)"}], "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "0AB20OqRQS81NUk9PVA", "detectionSourceAlias": "C42SE GDrive2", "fileId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/plain", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_213", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.086Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_210", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.079Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_201", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.075Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_199", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.992Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_207", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.037Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_205", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.090Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_204", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.088Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_202", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.077Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_211", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.082Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_200", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.014Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_198", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.988Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_208", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.071Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_212", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.084Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_209", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.074Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_206", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.012Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_203", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.080Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_169", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.759Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-19.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 228687, "fileOwner": "Administrators", "md5Checksum": "9da3457f38edd0e046c933175f46ca24", "sha256Checksum": "1d59d2c941afc4edc177ca6ea4bff0a0ff85b30c3d36498a68c46c157e93ebe5", "createTimestamp": "2019-02-07T18:16:40.414Z", "modifyTimestamp": "2019-02-07T18:16:40.781Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_167", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:18.732Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2019-02-07T18:14:54.402Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_172", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:14.775Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:34:54.617Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_168", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.788Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2019-02-07T18:21:40.645Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_171", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:16.780Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2018.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 888674, "fileOwner": "Administrators", "md5Checksum": "dd0bc4b60d44899ec14fedb3ba6e4ad9", "sha256Checksum": "4855a7290e8c0cb70ce2f12a7bd08ed0238d10176c54b78f79c27e309a56eb10", "createTimestamp": "2019-02-07T18:20:12.547Z", "modifyTimestamp": "2019-02-07T18:20:12.985Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_170", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.727Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-20.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 222829, "fileOwner": "Administrators", "md5Checksum": "de85d81335b089f30c3397e1174781e1", "sha256Checksum": "e049fc0fd048a49a8d0a581cd221af288d2f5882d7b88a88b46611e2037113aa", "createTimestamp": "2020-03-30T14:35:19.754Z", "modifyTimestamp": "2020-03-30T14:35:19.817Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_674", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:46.083Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:36:45.974Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_673", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.910Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-30T14:36:32.692Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_672", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.848Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-30T14:36:32.676Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_3", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_1", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:07.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_0", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_2", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_864", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_862", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_860", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_853", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_844", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_826", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "MississippiCloud1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 3897, "fileOwner": "jennifer.vang", "md5Checksum": "d790364577802d43b28e38249a4f01ef", "sha256Checksum": "7e22b9c6c7a19380acd28d699f866a0ee417b57f25b3e4240b95a34951b35685", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_822", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_814", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_811", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_859", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:25.813Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_851", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dragon-pan-_7l2FS4FicM-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10326110, "fileOwner": "jennifer.vang", "md5Checksum": "3a6aad3c9dea5aa2b04a84343270d767", "sha256Checksum": "3e7d1339057b496fe8d395c9cdbd7737a2da76f8d0c850503d175d209b2bb3c9", "createTimestamp": "2020-02-12T12:46:12Z", "modifyTimestamp": "2020-02-12T12:46:12Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_843", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:38.395Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_838", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:44.923Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_837", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_817", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_815", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "paul-hanaoka-a104tlUezug-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2818933, "fileOwner": "jennifer.vang", "md5Checksum": "68cc9c9d063c95303fafbc4a9a8b2d97", "sha256Checksum": "170779a12338948bff1e88aea7fd0c03d90b1c66fcb297f6476b1a4ec0ea82d5", "createTimestamp": "2020-02-12T12:45:52Z", "modifyTimestamp": "2020-02-12T12:45:52Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_813", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_872", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_833", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_830", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_828", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_821", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_819", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_873", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_867", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_852", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:11.204Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_834", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2018-12-10T21:28:34Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_824", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_820", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_870", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_869", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-12T12:46:26Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_866", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_863", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_850", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_842", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:39.654Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_841", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_839", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_829", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_865", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_849", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_847", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_835", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_871", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_861", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_856", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-13T16:10:19.557Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_836", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:53.508Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_818", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:29.161Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_858", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:23.312Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_857", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_855", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_840", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_831", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_827", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_868", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_854", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_848", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_846", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_845", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_832", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:47.431Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_825", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_823", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_816", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_808", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_807", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-12T12:46:18Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_804", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-12T12:46:34Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_793", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_791", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_790", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_783", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_774", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_767", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_763", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:15.688Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_760", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-12T12:46:20Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_744", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_801", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_786", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_785", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (114 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10416961, "fileOwner": "jennifer.vang", "md5Checksum": "b816ee1bf58595d8e5cd7a923e3eb8c9", "sha256Checksum": "a66691b862c63895e55079bce5a3a76c0b4863a436953549a802a872fe6bf4a2", "createTimestamp": "2018-12-10T21:30:22Z", "modifyTimestamp": "2018-12-10T21:30:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_756", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_752", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_745", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_803", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_766", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_751", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_742", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_799", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_796", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-12T12:55:04Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_795", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_772", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_769", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:18.105Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_762", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-12T12:46:10Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_757", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_748", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_780", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_775", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_770", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_768", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_765", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_764", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_755", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_743", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_737", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Cooper DB Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662448, "fileOwner": "jennifer.vang", "md5Checksum": "5b0ed4af0e989bde0339bc19ad61a8c3", "sha256Checksum": "390ec485088c848de1a5f260e220fcc0653651291b66124b80b11c14f9e6ff65", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_735", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "JaleelCRM2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4497, "fileOwner": "jennifer.vang", "md5Checksum": "741ef1acf2071b0f60d8487677f68e16", "sha256Checksum": "bc5b8e0924de3b4b143ac35201b85393015172cb8e894c879cf14d409669cc21", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_806", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_802", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_798", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_794", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_792", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_788", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_771", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_749", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:42.528Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_739", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.06.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407551, "fileOwner": "jennifer.vang", "md5Checksum": "72d06b6a958228513904082c644e0902", "sha256Checksum": "2cc02f5b08f626c9390851edbac05829d154e640635e49b6fb64b7f2647ffc61", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_805", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:20.918Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_784", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_778", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "BlackHornetStoryboard.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 1893, "fileOwner": "jennifer.vang", "md5Checksum": "3732d8580df54e63269e397eeba3de7d", "sha256Checksum": "b11ebfa2c83029db1929a18a2dbaf47fe1abda8c7af72882dcc3339d901ec958", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_776", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_773", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_758", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_753", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_747", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_746", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_738", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_736", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "MississippiCloud3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2657, "fileOwner": "jennifer.vang", "md5Checksum": "2b732f159cdaff64fee6bf922f4e6901", "sha256Checksum": "5ae69936410f58eaf5f290d753ba1e59daee654ea7035c30d665dbb7e469febc", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_797", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_789", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_777", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-12T12:47:00Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_754", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_740", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_733", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_809", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_800", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_787", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (119 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 11786180, "fileOwner": "jennifer.vang", "md5Checksum": "5fa18acf4fc97eb4bf8632a60b956a6c", "sha256Checksum": "4228073c4aee56559d664c0f35c02d7bea17809dc9a4be7efa890ad7e49a81a5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_782", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_781", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "Longfellow North Campus Network Diagram.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 6733, "fileOwner": "jennifer.vang", "md5Checksum": "0df46da580a4acb02e9e509da7e2ec32", "sha256Checksum": "8605a5edb90daeef9047f99bbd676de3c51b59d19ff44eefe4b4d1f89674c24e", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_779", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_761", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_759", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_750", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:44.240Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_741", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:45.628Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_734", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941983451917189059_947621656901949516_346", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:15.756Z", "insertionTimestamp": "2020-03-30T13:15:24.556Z", "filePath": "C:/Users/darnell.waters/OneDrive - Code42/", "fileName": ".849C9593-D756-4E56-8D6E-42412F2A707B", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 63, "fileOwner": "darnell.waters", "md5Checksum": "e37ee15a01960b22e4ece7f055532215", "sha256Checksum": "002d0c0a9f80d3bb5df04547e533553d4046d008bb88807627801157276b535c", "createTimestamp": "2020-02-19T21:48:30.549Z", "modifyTimestamp": "2020-03-30T12:51:09.790Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.39", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.39", "fe80:0:0:0:1d77:dcdf:c593:1143%eth2", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "941983451917189059", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "OneDrive", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_731", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:03.757Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Inscents.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 32346, "fileOwner": "kathy.kane", "md5Checksum": "6ec589b2e49feebe91b29c447c34fd99", "sha256Checksum": "b9fd589c001b4e8d96d2238e42412f80e039456d91f42fefebdfd055ed56504a", "createTimestamp": "2020-03-30T12:17:14.785Z", "modifyTimestamp": "2020-03-30T12:17:18.098Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_730", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:08.695Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "kathy.kane", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:14:10.543Z", "modifyTimestamp": "2020-03-30T12:14:12.757Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.430Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T11:53:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_811", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.461Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_809", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.368Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T12:02:48Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/octet-stream", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.492Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947613460610701533_502", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:03.888Z", "insertionTimestamp": "2020-03-30T11:53:59.251Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "SAC_Book_SecurityAwarenessPlaybook.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 3125834, "fileOwner": "jordan.anderson", "md5Checksum": "af3a63b4bbe732f1b7f17694e1762de8", "sha256Checksum": "7c558f359788befa3700e3c901caeb738ebc2475803cc347bebf42a692ee8724", "createTimestamp": "2019-05-22T17:17:57.425Z", "modifyTimestamp": "2019-05-22T17:17:59.481Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612912599718109_334", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:00.980Z", "insertionTimestamp": "2020-03-30T11:48:34.115Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "N-SOS-022_TheGlobalCostOfInsecurity.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 562441, "fileOwner": "jordan.anderson", "md5Checksum": "9d5b6ced2937c1bac8231e259560f0d4", "sha256Checksum": "0b3660bccde1197d521ca10d532f5e978ca31e78552466842c8c74e0fe5012fa", "createTimestamp": "2019-05-22T17:18:05.266Z", "modifyTimestamp": "2019-05-22T17:18:06.653Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612023390241449_126", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:36:55.028Z", "insertionTimestamp": "2020-03-30T11:39:43.734Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "jordan.anderson", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T11:20:50.858Z", "modifyTimestamp": "2020-03-30T11:20:55.671Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Inbox (9) - jordan.anderson@c42se.com - Code42 SE Mail - Mozilla Firefox"], "tabUrl": "https://mail.google.com/mail/u/0/#inbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_266", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:08.054Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunJenkinsSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 503, "fileOwner": "kathy.kane", "md5Checksum": "31e9b26ca9caeafd44b1d81d7fd216c3", "sha256Checksum": "e0de2ec27a9bb5ba229cd38c47d3015ab20345a6a92a0b2e3e8276c2e104bfa7", "createTimestamp": "2020-04-01T11:49:19.390Z", "modifyTimestamp": "2020-04-01T11:49:21.102Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_274", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:27.325Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver.exe", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Executable", "fileSize": 8543232, "fileOwner": "kathy.kane", "md5Checksum": "8ee62a8925030966a240521561e13f5a", "sha256Checksum": "66cfa645f83fde41720beac7061a559fd57b6f5caa83d7918f44de0f4dd27845", "createTimestamp": "2020-04-01T11:47:08.616Z", "modifyTimestamp": "2020-04-01T11:47:11.721Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-msdownload", "mimeTypeByExtension": "application/x-dosexec", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_269", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:07.038Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunSingleSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 490, "fileOwner": "kathy.kane", "md5Checksum": "075169d962d428547131e8669343b64b", "sha256Checksum": "336372de237f7f355550fdf8e48294c24a931f57a244176f58379e14f78d6f01", "createTimestamp": "2020-04-01T11:49:24.618Z", "modifyTimestamp": "2020-04-01T11:49:26.509Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_272", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:23.245Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Uncategorized", "fileSize": 14713200, "fileOwner": "kathy.kane", "md5Checksum": "f8999bb031325631ec685aba3c3266f5", "sha256Checksum": "b91856fda0fc769d8781dac5592b3f776f16b45b82b23fd636d45646e7d5d1f5", "createTimestamp": "2020-04-01T11:47:22.050Z", "modifyTimestamp": "2020-04-01T11:47:23.711Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-mach-o", "mimeTypeByExtension": "application/octet-stream", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_12", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.019Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.328Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_10", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.298Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_13", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.057Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.332Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_11", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.983Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.324Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_410", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.752Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToDecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1137, "fileOwner": "kathy.kane", "md5Checksum": "22f1e7d589972ca5fad60c8519d20e54", "sha256Checksum": "91fd221bf07accb12fb54f8a24349442a70a6f1e2a784e02d7b54c8183805613", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.765Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_411", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.736Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToHexadecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1642, "fileOwner": "kathy.kane", "md5Checksum": "985232edbb7900aa3def0a349718265e", "sha256Checksum": "0a9745b02fff401f03afbf571f11465373edaa1f63a8cb6f6503f4a5768ef9a2", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.827Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_409", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:51.298Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "IntegerToRoman.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1149, "fileOwner": "kathy.kane", "md5Checksum": "c10dce754394e1d1af170a9be3fef3f4", "sha256Checksum": "0c1bdae526817ae624223a8d3231ba3e1b6e8f67708e2db7eda1150477e7414a", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.886Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_412", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:53.816Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "RomanToInteger.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1441, "fileOwner": "kathy.kane", "md5Checksum": "d92e8215a4b799c8f9a2dec10218ab01", "sha256Checksum": "e2cd78b8a1a258b114648240eeeef7bdec5e68e54713174e0a01c0a7bb72a46c", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.796Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_80", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.784Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_81", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.815Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_79", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.753Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.237Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_78", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.034Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.472Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_294", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.897Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:31.116Z", "modifyTimestamp": "2019-08-12T16:41:56.988Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_292", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.554Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:31.038Z", "modifyTimestamp": "2019-08-12T16:41:57.139Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_293", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.803Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:31.069Z", "modifyTimestamp": "2019-08-12T16:41:55.779Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_291", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.366Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:31.007Z", "modifyTimestamp": "2019-08-12T16:41:56.842Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_324", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:58.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "MSA - Lackawanna Touring Company.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Document", "fileSize": 382094, "fileOwner": "kathy.kane", "md5Checksum": "39e21b6e0a1d4902c98baa5e3aeaba19", "sha256Checksum": "854156252e3ca1024050b7c20e76b3ede6649a48a3980899ef04ab9df534abc5", "createTimestamp": "2020-04-01T10:43:57.354Z", "modifyTimestamp": "2020-04-01T10:44:00.510Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_323", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.879Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "LTC - DC Replacement Project Plan.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 13635, "fileOwner": "kathy.kane", "md5Checksum": "3ef51bbb881c915bba30a6796553c005", "sha256Checksum": "4c3d8223b02f4299c80c0590dddd4c206f00b89419753fd9301b8cc992aa5fe9", "createTimestamp": "2020-04-01T10:43:36.417Z", "modifyTimestamp": "2020-04-01T10:43:39.729Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_322", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Lackawanna Touring Company.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Spreadsheet", "fileSize": 32354, "fileOwner": "kathy.kane", "md5Checksum": "aab45b5dd52dccb21a0e7e18bff9229e", "sha256Checksum": "90fa1ba4dfd2624c66e13ed6de7e676fb3558d2e4dd424aa2bbb5740b65b31cf", "createTimestamp": "2020-04-01T10:43:44.916Z", "modifyTimestamp": "2020-04-01T10:43:48.385Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_687", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:04.665Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "ZOOOOOOMYBoi.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 22137371, "fileOwner": "darnell.waters", "md5Checksum": "124fa909c632f80b70f016eecf440fd3", "sha256Checksum": "043173fb09f1001dcad6934dfd988b6fe91f6f03982dcc92dfe0292a93a4e803", "createTimestamp": "2020-02-06T15:42:20Z", "modifyTimestamp": "2020-02-19T19:11:17.378Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_685", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:49:09.123Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "GotWings.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 12654813, "fileOwner": "darnell.waters", "md5Checksum": "84958f28d8e3f0af82a9143fa98edc92", "sha256Checksum": "771acf81676efa85688fed2b7b0850a75cf6857d5998e9eab7c4247a3a48314e", "createTimestamp": "2020-02-06T15:25:30Z", "modifyTimestamp": "2020-02-19T19:11:18.539Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_686", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:20.332Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "THEBOSS.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 28262513, "fileOwner": "darnell.waters", "md5Checksum": "62eda4aada3ee1c7b18ab10970636b54", "sha256Checksum": "15f9d5e9ef79a3d6755b6df9b8406f3d0adf4abbab07d2b7df5645f71530554f", "createTimestamp": "2020-02-06T15:22:40Z", "modifyTimestamp": "2020-02-19T19:11:19.568Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_183", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:46:19.896Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_182", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:45:57.608Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_82", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.948Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-devportal.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1447, "fileOwner": "sean.cassidy", "md5Checksum": "0beee3cec377487154903f2d213c37fe", "sha256Checksum": "5a810a00d365c563314808e7c7934e531f327277e00ca6267976f036a170d28c", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.658Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_86", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.986Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-redis.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2100, "fileOwner": "sean.cassidy", "md5Checksum": "a340a797bd8e0981bf9dc9f3b4cd6f0c", "sha256Checksum": "f4a2b19821e2c8f096b2e74663bb0d2664046edf6b9f5c4b736b860c55ec933a", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.736Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_90", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.003Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds-rbac.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2004, "fileOwner": "sean.cassidy", "md5Checksum": "3905c4678af557eb44841c4bb2525b80", "sha256Checksum": "784d7f9cc3d709b7e1e7dbbfaa9027a887263ff78398f4ef4a5e0b43e1e64173", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.829Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_81", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.965Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "admin-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1491, "fileOwner": "sean.cassidy", "md5Checksum": "f61050ab8def08a384bbd0bed47c8cd6", "sha256Checksum": "84242f6fefff710efc16971b44479f81881ccccd3e96bc07a35c29ae99a04178", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.626Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_87", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.966Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2680, "fileOwner": "sean.cassidy", "md5Checksum": "080fc77a1284b0439dc8218df43668a9", "sha256Checksum": "5ad11226c30229686464543324255046f4d5a89c19f1fb6fda674b44f6c9fce3", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.752Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_83", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.928Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-auth.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1203, "fileOwner": "sean.cassidy", "md5Checksum": "d9b52beb12fd195f8bf347c4ea95df62", "sha256Checksum": "c2b67cb056dc7e4fa82dac3d3b18091922619c02e2783247fcb2c068987944d6", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.673Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_85", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:28.983Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-ratelimit.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 371, "fileOwner": "sean.cassidy", "md5Checksum": "ee51e7c14f3bafb58ea317d2173c1b79", "sha256Checksum": "86fec44093ad5c8aee2dd98f4686eb0e9b8fc98d8e0e5a5e4b762b47fb30c372", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.720Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_84", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.007Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-license-key-secret.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 227, "fileOwner": "sean.cassidy", "md5Checksum": "fd89e26a07fa4f8503fd40259f6d43d5", "sha256Checksum": "7395defcf955595295ba8c3ce16890fc4ee987311b2c801b1fc6a31a03053307", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.689Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_91", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:24.418Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 136, "fileOwner": "sean.cassidy", "md5Checksum": "fe3a88fb7c4f3032ddc75a50844d42fd", "sha256Checksum": "240083c41b206ada276328f0988b28a140bfd09f3b884e80463557db69d29d18", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.845Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_89", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.923Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crd-delete.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1621, "fileOwner": "sean.cassidy", "md5Checksum": "41b4d9e96a10d80087088eb06e3d92bd", "sha256Checksum": "b4fcecdc5b9a440d976e27adaa99d48d5eeacbc9a8c98827b0ce4c3a43f4cf01", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.798Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_88", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.946Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "config.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 605, "fileOwner": "sean.cassidy", "md5Checksum": "8042961777d6ee44573224233a9687ea", "sha256Checksum": "089cd64bda07824df9b16a51d9f9b2c3c3dd835624c39c6fb39bab562b65f038", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.783Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947792142030207362_434", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:24:35.336Z", "insertionTimestamp": "2020-03-31T17:29:02.459Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/", "fileName": "configure.py", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 57602, "fileOwner": "sean.cassidy", "md5Checksum": "75a4c54c9421b296c0a63a044029fad5", "sha256Checksum": "8ab6290f42c53c940f08f4fbe520ebd5e72d1dc85683b17783e38b89280f1a41", "createTimestamp": "2020-03-07T17:41:27.411Z", "modifyTimestamp": "2020-03-31T17:24:07.424Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.4.0\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-python", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947791329686485442_62", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:18:33.189Z", "insertionTimestamp": "2020-03-31T17:20:56.912Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "your-marketing-plan-template.doc", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Document", "fileSize": 45568, "fileOwner": "Administrators", "md5Checksum": "6bb8604e540d3df44f18db72dfd5908f", "sha256Checksum": "4e121eed4819e5586930844475505da498f9ce424d3d43595a0d3473bfada2fc", "createTimestamp": "2019-02-07T16:23:05.662Z", "modifyTimestamp": "2019-02-07T16:23:06.908Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (54) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-tika-msoffice", "mimeTypeByExtension": "application/msword", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947790854009780610_771", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:02:39.749Z", "insertionTimestamp": "2020-03-31T17:16:14.843Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/HashMaker/", "fileName": "BlockAllocator.h", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "SourceCode", "fileCategoryByExtension": "SourceCode", "fileSize": 3549, "fileOwner": "sean.cassidy", "md5Checksum": "601f6f6fc877d60922b9c1012370232c", "sha256Checksum": "f57cae2718ffea77ddb86fb0f95b214651626b167712ae2d0f9306259a7a6907", "createTimestamp": "2020-03-19T01:38:00Z", "modifyTimestamp": "2020-03-19T03:43:46.973Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.3.1\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/x-csrc", "mimeTypeByExtension": "text/x-chdr", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947790235711338946_143", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:08:56.530Z", "insertionTimestamp": "2020-03-31T17:10:04.773Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "The Chiropractic Report Chapman Referral Letters.PDF", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 503962, "fileOwner": "Administrators", "md5Checksum": "9c0b34317626ab2b393d48e8f726569e", "sha256Checksum": "0536562c0e47848c6dcab72cade08eefeea5a0c67cb3c0b92f79d7b585522807", "createTimestamp": "2018-10-02T19:13:47.218Z", "modifyTimestamp": "2018-03-21T21:22:48.303Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (53) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_411", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:29.955Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T16:57:23.132Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_410", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.063Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T16:57:23.141Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_409", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.028Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T16:57:23.131Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_269", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.207Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_271", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.218Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_270", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.214Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_266", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.189Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_265", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.188Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_260", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.215Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_259", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.190Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_263", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.222Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_262", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.221Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_261", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.217Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_258", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.186Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_257", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.183Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_268", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.206Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_256", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.181Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_267", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.192Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_264", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.184Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_117", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.327Z", "insertionTimestamp": "2020-03-31T11:04:15.595Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T11:00:48.869Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_115", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:51.545Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T11:00:48.353Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_116", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.389Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T11:00:48.885Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "643502901225__8749df16-e136-4268-bc85-5323f8db2597", "eventType": "MODIFIED", "eventTimestamp": "2020-03-31T03:08:06.978Z", "insertionTimestamp": "2020-03-31T09:02:25.372Z", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 56653, "fileOwner": "kathy.kane@c42se.com", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:17:38Z", "modifyTimestamp": "2020-03-30T12:17:38Z", "actor": "kathy.kane@c42se.com", "directoryId": ["108056515629"], "source": "Box", "url": "https://code42a.box.com/s/sblis4r0zr5p0rbrr87fu3zml8svej58", "shared": "TRUE", "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "9981852168", "detectionSourceAlias": "C42 SE Box", "fileId": "643502901225", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk_2_9164694a-48e8-4c89-aed8-36d51d6338d4", "eventType": "CREATED", "eventTimestamp": "2020-03-30T15:29:52.894Z", "insertionTimestamp": "2020-03-31T00:01:48.913Z", "fileName": "9.29 Meeting Notes.txt", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "Document", "fileSize": 8089, "fileOwner": "george.washington@c42se.com", "md5Checksum": "86eb5a3c9d0ea6b6c37d3f988f42c718", "sha256Checksum": "4468e007b9b4a8050c10d29b3c9b38ea66d896389b1c27c4d030e129ab0ab688", "createTimestamp": "2020-03-30T15:05:27.880Z", "modifyTimestamp": "2020-03-30T15:05:39.871Z", "actor": "george.washington@c42se.com", "directoryId": ["0AB20OqRQS81NUk9PVA"], "source": "GoogleDrive", "url": "https://drive.google.com/a/c42se.com/file/d/1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk/view?usp=drivesdk", "shared": "TRUE", "sharedWith": [{"cloudUsername": "External (Public)"}], "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "0AB20OqRQS81NUk9PVA", "detectionSourceAlias": "C42SE GDrive2", "fileId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/plain", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_213", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.086Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_210", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.079Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_201", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.075Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_199", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.992Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_207", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.037Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_205", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.090Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_204", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.088Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_202", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.077Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_211", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.082Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_200", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.014Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_198", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.988Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_208", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.071Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_212", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.084Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_209", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.074Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_206", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.012Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_203", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.080Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_169", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.759Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-19.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 228687, "fileOwner": "Administrators", "md5Checksum": "9da3457f38edd0e046c933175f46ca24", "sha256Checksum": "1d59d2c941afc4edc177ca6ea4bff0a0ff85b30c3d36498a68c46c157e93ebe5", "createTimestamp": "2019-02-07T18:16:40.414Z", "modifyTimestamp": "2019-02-07T18:16:40.781Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_167", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:18.732Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2019-02-07T18:14:54.402Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_172", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:14.775Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:34:54.617Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_168", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.788Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2019-02-07T18:21:40.645Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_171", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:16.780Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2018.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 888674, "fileOwner": "Administrators", "md5Checksum": "dd0bc4b60d44899ec14fedb3ba6e4ad9", "sha256Checksum": "4855a7290e8c0cb70ce2f12a7bd08ed0238d10176c54b78f79c27e309a56eb10", "createTimestamp": "2019-02-07T18:20:12.547Z", "modifyTimestamp": "2019-02-07T18:20:12.985Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_170", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.727Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-20.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 222829, "fileOwner": "Administrators", "md5Checksum": "de85d81335b089f30c3397e1174781e1", "sha256Checksum": "e049fc0fd048a49a8d0a581cd221af288d2f5882d7b88a88b46611e2037113aa", "createTimestamp": "2020-03-30T14:35:19.754Z", "modifyTimestamp": "2020-03-30T14:35:19.817Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_674", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:46.083Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:36:45.974Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_673", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.910Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-30T14:36:32.692Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_672", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.848Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-30T14:36:32.676Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_3", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_1", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:07.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_0", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_2", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_864", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_862", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_860", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_853", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_844", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_826", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "MississippiCloud1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 3897, "fileOwner": "jennifer.vang", "md5Checksum": "d790364577802d43b28e38249a4f01ef", "sha256Checksum": "7e22b9c6c7a19380acd28d699f866a0ee417b57f25b3e4240b95a34951b35685", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_822", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_814", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_811", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_859", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:25.813Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_851", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dragon-pan-_7l2FS4FicM-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10326110, "fileOwner": "jennifer.vang", "md5Checksum": "3a6aad3c9dea5aa2b04a84343270d767", "sha256Checksum": "3e7d1339057b496fe8d395c9cdbd7737a2da76f8d0c850503d175d209b2bb3c9", "createTimestamp": "2020-02-12T12:46:12Z", "modifyTimestamp": "2020-02-12T12:46:12Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_843", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:38.395Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_838", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:44.923Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_837", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_817", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_815", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "paul-hanaoka-a104tlUezug-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2818933, "fileOwner": "jennifer.vang", "md5Checksum": "68cc9c9d063c95303fafbc4a9a8b2d97", "sha256Checksum": "170779a12338948bff1e88aea7fd0c03d90b1c66fcb297f6476b1a4ec0ea82d5", "createTimestamp": "2020-02-12T12:45:52Z", "modifyTimestamp": "2020-02-12T12:45:52Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_813", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_872", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_833", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_830", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_828", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_821", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_819", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_873", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_867", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_852", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:11.204Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_834", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2018-12-10T21:28:34Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_824", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_820", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_870", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_869", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-12T12:46:26Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_866", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_863", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_850", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_842", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:39.654Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_841", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_839", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_829", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_865", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_849", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_847", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_835", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_871", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_861", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_856", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-13T16:10:19.557Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_836", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:53.508Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_818", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:29.161Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_858", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:23.312Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_857", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_855", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_840", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_831", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_827", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_868", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_854", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_848", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_846", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_845", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_832", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:47.431Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_825", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_823", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_816", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_808", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_807", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-12T12:46:18Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_804", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-12T12:46:34Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_793", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_791", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_790", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_783", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_774", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_767", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_763", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:15.688Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_760", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-12T12:46:20Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_744", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_801", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_786", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_785", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (114 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10416961, "fileOwner": "jennifer.vang", "md5Checksum": "b816ee1bf58595d8e5cd7a923e3eb8c9", "sha256Checksum": "a66691b862c63895e55079bce5a3a76c0b4863a436953549a802a872fe6bf4a2", "createTimestamp": "2018-12-10T21:30:22Z", "modifyTimestamp": "2018-12-10T21:30:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_756", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_752", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_745", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_803", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_766", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_751", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_742", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_799", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_796", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-12T12:55:04Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_795", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_772", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_769", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:18.105Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_762", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-12T12:46:10Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_757", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_748", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_780", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_775", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_770", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_768", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_765", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_764", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_755", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_743", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_737", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Cooper DB Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662448, "fileOwner": "jennifer.vang", "md5Checksum": "5b0ed4af0e989bde0339bc19ad61a8c3", "sha256Checksum": "390ec485088c848de1a5f260e220fcc0653651291b66124b80b11c14f9e6ff65", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_735", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "JaleelCRM2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4497, "fileOwner": "jennifer.vang", "md5Checksum": "741ef1acf2071b0f60d8487677f68e16", "sha256Checksum": "bc5b8e0924de3b4b143ac35201b85393015172cb8e894c879cf14d409669cc21", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_806", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_802", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_798", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_794", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_792", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_788", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_771", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_749", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:42.528Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_739", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.06.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407551, "fileOwner": "jennifer.vang", "md5Checksum": "72d06b6a958228513904082c644e0902", "sha256Checksum": "2cc02f5b08f626c9390851edbac05829d154e640635e49b6fb64b7f2647ffc61", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_805", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:20.918Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_784", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_778", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "BlackHornetStoryboard.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 1893, "fileOwner": "jennifer.vang", "md5Checksum": "3732d8580df54e63269e397eeba3de7d", "sha256Checksum": "b11ebfa2c83029db1929a18a2dbaf47fe1abda8c7af72882dcc3339d901ec958", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_776", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_773", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_758", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_753", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_747", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_746", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_738", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_736", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "MississippiCloud3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2657, "fileOwner": "jennifer.vang", "md5Checksum": "2b732f159cdaff64fee6bf922f4e6901", "sha256Checksum": "5ae69936410f58eaf5f290d753ba1e59daee654ea7035c30d665dbb7e469febc", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_797", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_789", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_777", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-12T12:47:00Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_754", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_740", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_733", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_809", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_800", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_787", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (119 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 11786180, "fileOwner": "jennifer.vang", "md5Checksum": "5fa18acf4fc97eb4bf8632a60b956a6c", "sha256Checksum": "4228073c4aee56559d664c0f35c02d7bea17809dc9a4be7efa890ad7e49a81a5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_782", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_781", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "Longfellow North Campus Network Diagram.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 6733, "fileOwner": "jennifer.vang", "md5Checksum": "0df46da580a4acb02e9e509da7e2ec32", "sha256Checksum": "8605a5edb90daeef9047f99bbd676de3c51b59d19ff44eefe4b4d1f89674c24e", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_779", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_761", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_759", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_750", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:44.240Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_741", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:45.628Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_734", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941983451917189059_947621656901949516_346", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:15.756Z", "insertionTimestamp": "2020-03-30T13:15:24.556Z", "filePath": "C:/Users/darnell.waters/OneDrive - Code42/", "fileName": ".849C9593-D756-4E56-8D6E-42412F2A707B", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 63, "fileOwner": "darnell.waters", "md5Checksum": "e37ee15a01960b22e4ece7f055532215", "sha256Checksum": "002d0c0a9f80d3bb5df04547e533553d4046d008bb88807627801157276b535c", "createTimestamp": "2020-02-19T21:48:30.549Z", "modifyTimestamp": "2020-03-30T12:51:09.790Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.39", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.39", "fe80:0:0:0:1d77:dcdf:c593:1143%eth2", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "941983451917189059", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "OneDrive", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_731", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:03.757Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Inscents.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 32346, "fileOwner": "kathy.kane", "md5Checksum": "6ec589b2e49feebe91b29c447c34fd99", "sha256Checksum": "b9fd589c001b4e8d96d2238e42412f80e039456d91f42fefebdfd055ed56504a", "createTimestamp": "2020-03-30T12:17:14.785Z", "modifyTimestamp": "2020-03-30T12:17:18.098Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_730", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:08.695Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "kathy.kane", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:14:10.543Z", "modifyTimestamp": "2020-03-30T12:14:12.757Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.430Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T11:53:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_811", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.461Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_809", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.368Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T12:02:48Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/octet-stream", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.492Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947613460610701533_502", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:03.888Z", "insertionTimestamp": "2020-03-30T11:53:59.251Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "SAC_Book_SecurityAwarenessPlaybook.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 3125834, "fileOwner": "jordan.anderson", "md5Checksum": "af3a63b4bbe732f1b7f17694e1762de8", "sha256Checksum": "7c558f359788befa3700e3c901caeb738ebc2475803cc347bebf42a692ee8724", "createTimestamp": "2019-05-22T17:17:57.425Z", "modifyTimestamp": "2019-05-22T17:17:59.481Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612912599718109_334", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:00.980Z", "insertionTimestamp": "2020-03-30T11:48:34.115Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "N-SOS-022_TheGlobalCostOfInsecurity.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 562441, "fileOwner": "jordan.anderson", "md5Checksum": "9d5b6ced2937c1bac8231e259560f0d4", "sha256Checksum": "0b3660bccde1197d521ca10d532f5e978ca31e78552466842c8c74e0fe5012fa", "createTimestamp": "2019-05-22T17:18:05.266Z", "modifyTimestamp": "2019-05-22T17:18:06.653Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612023390241449_126", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:36:55.028Z", "insertionTimestamp": "2020-03-30T11:39:43.734Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "jordan.anderson", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T11:20:50.858Z", "modifyTimestamp": "2020-03-30T11:20:55.671Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Inbox (9) - jordan.anderson@c42se.com - Code42 SE Mail - Mozilla Firefox"], "tabUrl": "https://mail.google.com/mail/u/0/#inbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_266", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:08.054Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunJenkinsSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 503, "fileOwner": "kathy.kane", "md5Checksum": "31e9b26ca9caeafd44b1d81d7fd216c3", "sha256Checksum": "e0de2ec27a9bb5ba229cd38c47d3015ab20345a6a92a0b2e3e8276c2e104bfa7", "createTimestamp": "2020-04-01T11:49:19.390Z", "modifyTimestamp": "2020-04-01T11:49:21.102Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_274", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:27.325Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver.exe", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Executable", "fileSize": 8543232, "fileOwner": "kathy.kane", "md5Checksum": "8ee62a8925030966a240521561e13f5a", "sha256Checksum": "66cfa645f83fde41720beac7061a559fd57b6f5caa83d7918f44de0f4dd27845", "createTimestamp": "2020-04-01T11:47:08.616Z", "modifyTimestamp": "2020-04-01T11:47:11.721Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-msdownload", "mimeTypeByExtension": "application/x-dosexec", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_269", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:50:07.038Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "RunSingleSuite.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 490, "fileOwner": "kathy.kane", "md5Checksum": "075169d962d428547131e8669343b64b", "sha256Checksum": "336372de237f7f355550fdf8e48294c24a931f57a244176f58379e14f78d6f01", "createTimestamp": "2020-04-01T11:49:24.618Z", "modifyTimestamp": "2020-04-01T11:49:26.509Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947903382298366276_272", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T11:48:23.245Z", "insertionTimestamp": "2020-04-01T11:54:05.634Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "chromedriver", "fileType": "FILE", "fileCategory": "EXECUTABLE", "fileCategoryByBytes": "Executable", "fileCategoryByExtension": "Uncategorized", "fileSize": 14713200, "fileOwner": "kathy.kane", "md5Checksum": "f8999bb031325631ec685aba3c3266f5", "sha256Checksum": "b91856fda0fc769d8781dac5592b3f776f16b45b82b23fd636d45646e7d5d1f5", "createTimestamp": "2020-04-01T11:47:22.050Z", "modifyTimestamp": "2020-04-01T11:47:23.711Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-mach-o", "mimeTypeByExtension": "application/octet-stream", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_12", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.019Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.328Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_10", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.298Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_13", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:36.057Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.332Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947897987539178938_11", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.983Z", "insertionTimestamp": "2020-04-01T11:00:52.342Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.324Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_410", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.752Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToDecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1137, "fileOwner": "kathy.kane", "md5Checksum": "22f1e7d589972ca5fad60c8519d20e54", "sha256Checksum": "91fd221bf07accb12fb54f8a24349442a70a6f1e2a784e02d7b54c8183805613", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.765Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_411", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:54.736Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "OctalToHexadecimal.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1642, "fileOwner": "kathy.kane", "md5Checksum": "985232edbb7900aa3def0a349718265e", "sha256Checksum": "0a9745b02fff401f03afbf571f11465373edaa1f63a8cb6f6503f4a5768ef9a2", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.827Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_409", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:51.298Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "IntegerToRoman.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1149, "fileOwner": "kathy.kane", "md5Checksum": "c10dce754394e1d1af170a9be3fef3f4", "sha256Checksum": "0c1bdae526817ae624223a8d3231ba3e1b6e8f67708e2db7eda1150477e7414a", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.886Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897876385173828_412", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:58:53.816Z", "insertionTimestamp": "2020-04-01T10:59:40.435Z", "filePath": "C:/Users/kathy.kane/Downloads/code-20200401T105016Z-001/code/", "fileName": "RomanToInteger.java", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1441, "fileOwner": "kathy.kane", "md5Checksum": "d92e8215a4b799c8f9a2dec10218ab01", "sha256Checksum": "e2cd78b8a1a258b114648240eeeef7bdec5e68e54713174e0a01c0a7bb72a46c", "createTimestamp": "2020-02-18T18:36:22Z", "modifyTimestamp": "2020-04-01T10:52:02.796Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Home - Dropbox - Mozilla Firefox"], "tabUrl": "https://www.dropbox.com/h", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-java-source", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_80", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.784Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:55Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_81", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.815Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:35.253Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_79", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.753Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:35.237Z", "modifyTimestamp": "2019-08-12T16:41:57Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947897700817459044_78", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:35.034Z", "insertionTimestamp": "2020-04-01T10:57:41.792Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:34.472Z", "modifyTimestamp": "2019-08-12T16:41:56Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_294", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.897Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report7201967845635.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8790, "fileOwner": "Administrators", "md5Checksum": "c515eaa706ddae6e13a67dae8ac70b7d", "sha256Checksum": "5634345d08c99acd9afeab1ebcfe0d44ad3b8791a756fd01d8fa1877b33257e0", "createTimestamp": "2020-04-01T10:55:31.116Z", "modifyTimestamp": "2019-08-12T16:41:56.988Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_292", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.554Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2601912340699.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8752, "fileOwner": "Administrators", "md5Checksum": "21eea26d3fa5e71d5509bf0de3ba32cf", "sha256Checksum": "df7b774b690496dded45e10d0836274f464afd2f60765c2d24139d8fe88c054f", "createTimestamp": "2020-04-01T10:55:31.038Z", "modifyTimestamp": "2019-08-12T16:41:57.139Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_293", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.803Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report3207972345691.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8769, "fileOwner": "Administrators", "md5Checksum": "b3a872020d04485d0ab3a8a75c233c4e", "sha256Checksum": "387aa3440a1fdd57750a66b8b421216c9e62ba8772d8e714203de4359dde2b4b", "createTimestamp": "2020-04-01T10:55:31.069Z", "modifyTimestamp": "2019-08-12T16:41:55.779Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947897565123515647_291", "eventType": "CREATED", "eventTimestamp": "2020-04-01T10:55:31.366Z", "insertionTimestamp": "2020-04-01T10:56:19.231Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/Sales Reports/", "fileName": "report2201912385696.xls", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 8770, "fileOwner": "Administrators", "md5Checksum": "7b7af7fd162ef2606e37ff1e8829191a", "sha256Checksum": "a07098c83761cd79bcee40a1fc9662b6a26135e5ed331de807c516b8a2873b69", "createTimestamp": "2020-04-01T10:55:31.007Z", "modifyTimestamp": "2019-08-12T16:41:56.842Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.ms-excel", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_324", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:58.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "MSA - Lackawanna Touring Company.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Document", "fileSize": 382094, "fileOwner": "kathy.kane", "md5Checksum": "39e21b6e0a1d4902c98baa5e3aeaba19", "sha256Checksum": "854156252e3ca1024050b7c20e76b3ede6649a48a3980899ef04ab9df534abc5", "createTimestamp": "2020-04-01T10:43:57.354Z", "modifyTimestamp": "2020-04-01T10:44:00.510Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_323", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.879Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "LTC - DC Replacement Project Plan.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Spreadsheet", "fileCategoryByExtension": "Spreadsheet", "fileSize": 13635, "fileOwner": "kathy.kane", "md5Checksum": "3ef51bbb881c915bba30a6796553c005", "sha256Checksum": "4c3d8223b02f4299c80c0590dddd4c206f00b89419753fd9301b8cc992aa5fe9", "createTimestamp": "2020-04-01T10:43:36.417Z", "modifyTimestamp": "2020-04-01T10:43:39.729Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947897369461592388_322", "eventType": "READ_BY_APP", "eventTimestamp": "2020-04-01T10:48:59.910Z", "insertionTimestamp": "2020-04-01T10:54:21.103Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Lackawanna Touring Company.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileCategoryByBytes": "Archive", "fileCategoryByExtension": "Spreadsheet", "fileSize": 32354, "fileOwner": "kathy.kane", "md5Checksum": "aab45b5dd52dccb21a0e7e18bff9229e", "sha256Checksum": "90fa1ba4dfd2624c66e13ed6de7e676fb3558d2e4dd424aa2bbb5740b65b31cf", "createTimestamp": "2020-04-01T10:43:44.916Z", "modifyTimestamp": "2020-04-01T10:43:48.385Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:7530:da52:30b6:cfc7%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "outsideActiveHours": false, "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_687", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:04.665Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "ZOOOOOOMYBoi.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 22137371, "fileOwner": "darnell.waters", "md5Checksum": "124fa909c632f80b70f016eecf440fd3", "sha256Checksum": "043173fb09f1001dcad6934dfd988b6fe91f6f03982dcc92dfe0292a93a4e803", "createTimestamp": "2020-02-06T15:42:20Z", "modifyTimestamp": "2020-02-19T19:11:17.378Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_685", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:49:09.123Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "GotWings.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 12654813, "fileOwner": "darnell.waters", "md5Checksum": "84958f28d8e3f0af82a9143fa98edc92", "sha256Checksum": "771acf81676efa85688fed2b7b0850a75cf6857d5998e9eab7c4247a3a48314e", "createTimestamp": "2020-02-06T15:25:30Z", "modifyTimestamp": "2020-02-19T19:11:18.539Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947801139750789143_686", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:51:20.332Z", "insertionTimestamp": "2020-03-31T18:58:24.712Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "THEBOSS.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 28262513, "fileOwner": "darnell.waters", "md5Checksum": "62eda4aada3ee1c7b18ab10970636b54", "sha256Checksum": "15f9d5e9ef79a3d6755b6df9b8406f3d0adf4abbab07d2b7df5645f71530554f", "createTimestamp": "2020-02-06T15:22:40Z", "modifyTimestamp": "2020-02-19T19:11:19.568Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_183", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:46:19.896Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_902443373841117412_947800303658229783_182", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:45:57.608Z", "insertionTimestamp": "2020-03-31T18:50:06.508Z", "filePath": "C:/Users/darnell.waters/Pictures/final/", "fileName": "renaultPersian.png", "fileType": "FILE", "fileCategory": "IMAGE", "fileCategoryByBytes": "Image", "fileCategoryByExtension": "Image", "fileSize": 14033293, "fileOwner": "darnell.waters", "md5Checksum": "f04a4f1333c723c0458a0266cf5b2408", "sha256Checksum": "5e0a91363eb75791b0a2ca22decaa1ac17d4e0920657f90358a74f634f2f8e5d", "createTimestamp": "2020-02-06T15:32:04Z", "modifyTimestamp": "2020-02-19T19:11:17.855Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "LAPTOP-012", "domainName": "10.0.1.24", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.24", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:bd2b:9ac6:5b3a:b47f%eth0"], "deviceUid": "902443373841117412", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "darnell.waters", "processName": "\\Device\\HarddiskVolume2\\Users\\darnell.waters\\AppData\\Local\\slack\\app-4.3.4\\slack.exe", "windowTitle": ["Slack | cats_omg | Sysadmin buddies"], "outsideActiveHours": false, "mimeTypeByBytes": "image/png", "mimeTypeByExtension": "image/png", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_82", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.948Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-devportal.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1447, "fileOwner": "sean.cassidy", "md5Checksum": "0beee3cec377487154903f2d213c37fe", "sha256Checksum": "5a810a00d365c563314808e7c7934e531f327277e00ca6267976f036a170d28c", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.658Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_86", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.986Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-redis.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2100, "fileOwner": "sean.cassidy", "md5Checksum": "a340a797bd8e0981bf9dc9f3b4cd6f0c", "sha256Checksum": "f4a2b19821e2c8f096b2e74663bb0d2664046edf6b9f5c4b736b860c55ec933a", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.736Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_90", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.003Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds-rbac.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2004, "fileOwner": "sean.cassidy", "md5Checksum": "3905c4678af557eb44841c4bb2525b80", "sha256Checksum": "784d7f9cc3d709b7e1e7dbbfaa9027a887263ff78398f4ef4a5e0b43e1e64173", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.829Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_81", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.965Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "admin-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1491, "fileOwner": "sean.cassidy", "md5Checksum": "f61050ab8def08a384bbd0bed47c8cd6", "sha256Checksum": "84242f6fefff710efc16971b44479f81881ccccd3e96bc07a35c29ae99a04178", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.626Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_87", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.966Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-service.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 2680, "fileOwner": "sean.cassidy", "md5Checksum": "080fc77a1284b0439dc8218df43668a9", "sha256Checksum": "5ad11226c30229686464543324255046f4d5a89c19f1fb6fda674b44f6c9fce3", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.752Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_83", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.928Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-auth.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1203, "fileOwner": "sean.cassidy", "md5Checksum": "d9b52beb12fd195f8bf347c4ea95df62", "sha256Checksum": "c2b67cb056dc7e4fa82dac3d3b18091922619c02e2783247fcb2c068987944d6", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.673Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_85", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:28.983Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-ratelimit.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 371, "fileOwner": "sean.cassidy", "md5Checksum": "ee51e7c14f3bafb58ea317d2173c1b79", "sha256Checksum": "86fec44093ad5c8aee2dd98f4686eb0e9b8fc98d8e0e5a5e4b762b47fb30c372", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.720Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_84", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:27.007Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "ambassador-pro-license-key-secret.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 227, "fileOwner": "sean.cassidy", "md5Checksum": "fd89e26a07fa4f8503fd40259f6d43d5", "sha256Checksum": "7395defcf955595295ba8c3ce16890fc4ee987311b2c801b1fc6a31a03053307", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.689Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_91", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:24.418Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crds.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 136, "fileOwner": "sean.cassidy", "md5Checksum": "fe3a88fb7c4f3032ddc75a50844d42fd", "sha256Checksum": "240083c41b206ada276328f0988b28a140bfd09f3b884e80463557db69d29d18", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.845Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_89", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.923Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "crd-delete.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 1621, "fileOwner": "sean.cassidy", "md5Checksum": "41b4d9e96a10d80087088eb06e3d92bd", "sha256Checksum": "b4fcecdc5b9a440d976e27adaa99d48d5eeacbc9a8c98827b0ce4c3a43f4cf01", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.798Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947798035765524866_88", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T18:22:26.946Z", "insertionTimestamp": "2020-03-31T18:27:35.783Z", "filePath": "C:/Users/sean.cassidy/Downloads/charts-master/stable/ambassador/templates/", "fileName": "config.yaml", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 605, "fileOwner": "sean.cassidy", "md5Checksum": "8042961777d6ee44573224233a9687ea", "sha256Checksum": "089cd64bda07824df9b16a51d9f9b2c3c3dd835624c39c6fb39bab562b65f038", "createTimestamp": "2020-03-30T20:00:40Z", "modifyTimestamp": "2020-03-31T18:17:31.783Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["ambassador - Software Development - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/folders/1FZw3MVGVIpZY-vmihv-GaafakUaDodch", "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-yaml", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947792142030207362_434", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:24:35.336Z", "insertionTimestamp": "2020-03-31T17:29:02.459Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/", "fileName": "configure.py", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "SourceCode", "fileSize": 57602, "fileOwner": "sean.cassidy", "md5Checksum": "75a4c54c9421b296c0a63a044029fad5", "sha256Checksum": "8ab6290f42c53c940f08f4fbe520ebd5e72d1dc85683b17783e38b89280f1a41", "createTimestamp": "2020-03-07T17:41:27.411Z", "modifyTimestamp": "2020-03-31T17:24:07.424Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.4.0\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/x-python", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947791329686485442_62", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:18:33.189Z", "insertionTimestamp": "2020-03-31T17:20:56.912Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "your-marketing-plan-template.doc", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Document", "fileSize": 45568, "fileOwner": "Administrators", "md5Checksum": "6bb8604e540d3df44f18db72dfd5908f", "sha256Checksum": "4e121eed4819e5586930844475505da498f9ce424d3d43595a0d3473bfada2fc", "createTimestamp": "2019-02-07T16:23:05.662Z", "modifyTimestamp": "2019-02-07T16:23:06.908Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (54) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/x-tika-msoffice", "mimeTypeByExtension": "application/msword", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947790854009780610_771", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:02:39.749Z", "insertionTimestamp": "2020-03-31T17:16:14.843Z", "filePath": "C:/Users/sean.cassidy/Documents/GitHub/cassCode/HashMaker/", "fileName": "BlockAllocator.h", "fileType": "FILE", "fileCategory": "SOURCE_CODE", "fileCategoryByBytes": "SourceCode", "fileCategoryByExtension": "SourceCode", "fileSize": 3549, "fileOwner": "sean.cassidy", "md5Checksum": "601f6f6fc877d60922b9c1012370232c", "sha256Checksum": "f57cae2718ffea77ddb86fb0f95b214651626b167712ae2d0f9306259a7a6907", "createTimestamp": "2020-03-19T01:38:00Z", "modifyTimestamp": "2020-03-19T03:43:46.973Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "sean.cassidy", "processName": "\\Device\\HarddiskVolume1\\Users\\sean.cassidy\\AppData\\Local\\GitHubDesktop\\app-2.3.1\\GitHubDesktop.exe", "windowTitle": ["GitHub Desktop"], "outsideActiveHours": false, "mimeTypeByBytes": "text/x-csrc", "mimeTypeByExtension": "text/x-chdr", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947790235711338946_143", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-31T17:08:56.530Z", "insertionTimestamp": "2020-03-31T17:10:04.773Z", "filePath": "C:/Users/john.lamonica/Downloads/", "fileName": "The Chiropractic Report Chapman Referral Letters.PDF", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 503962, "fileOwner": "Administrators", "md5Checksum": "9c0b34317626ab2b393d48e8f726569e", "sha256Checksum": "0536562c0e47848c6dcab72cade08eefeea5a0c67cb3c0b92f79d7b585522807", "createTimestamp": "2018-10-02T19:13:47.218Z", "modifyTimestamp": "2018-03-21T21:22:48.303Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["Inbox (53) - john.lamonica@c42se.com - Code42 SE Mail - Google Chrome"], "tabUrl": "https://mail.google.com/mail/u/0/?tab=rm1#inbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_411", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:29.955Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T16:57:23.132Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_410", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.063Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T16:57:23.141Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886929421760133171_947789496613795745_409", "eventType": "CREATED", "eventTimestamp": "2020-03-31T16:57:30.028Z", "insertionTimestamp": "2020-03-31T17:02:45.232Z", "filePath": "C:/Users/eric.strauss/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T16:57:23.131Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "eric.strauss@c42se.com", "osHostName": "DESKTOP-005", "domainName": "DESKTOP-005.edu.code42.com", "publicIpAddress": "76.191.118.1", "privateIpAddresses": ["10.0.1.9", "fe80:0:0:0:e030:cc78:38c5:7211%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886929421760133171", "userUid": "886924612955838070", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_269", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.207Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_271", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.218Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_270", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.214Z", "insertionTimestamp": "2020-03-31T16:20:40.125Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_266", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.189Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_265", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.188Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_260", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.215Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_259", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.190Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_263", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.222Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_262", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.221Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_261", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.217Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_258", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.186Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_257", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.183Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_268", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.206Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_256", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.181Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_267", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.192Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947785260579607940_264", "eventType": "DELETED", "eventTimestamp": "2020-03-31T16:19:45.184Z", "insertionTimestamp": "2020-03-31T16:20:40.124Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947785172146902404/", "fileName": ".testWriteFile947785172146902404", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "fileCategoryByBytes": "Uncategorized", "fileCategoryByExtension": "Uncategorized", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "outsideActiveHours": false, "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_117", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.327Z", "insertionTimestamp": "2020-03-31T11:04:15.595Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-31T11:00:48.869Z", "modifyTimestamp": "2020-03-30T14:34:54Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_115", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:51.545Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-31T11:00:48.353Z", "modifyTimestamp": "2020-03-30T14:33:26Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886938361183453868_947753408796746702_116", "eventType": "CREATED", "eventTimestamp": "2020-03-31T11:00:52.389Z", "insertionTimestamp": "2020-03-31T11:04:15.594Z", "filePath": "C:/Users/jim.harper/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-31T11:00:48.885Z", "modifyTimestamp": "2020-03-30T14:34:11Z", "deviceUserName": "jim.harper@c42se.com", "osHostName": "LAPTOP-007", "domainName": "LAPTOP-007.edu.code42.com", "publicIpAddress": "76.191.118.6", "privateIpAddresses": ["10.0.1.10", "fe80:0:0:0:1c7e:61f0:cff6:f2fb%eth3", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "886938361183453868", "userUid": "886933071206061686", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "643502901225__8749df16-e136-4268-bc85-5323f8db2597", "eventType": "MODIFIED", "eventTimestamp": "2020-03-31T03:08:06.978Z", "insertionTimestamp": "2020-03-31T09:02:25.372Z", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileCategoryByBytes": "Pdf", "fileCategoryByExtension": "Pdf", "fileSize": 56653, "fileOwner": "kathy.kane@c42se.com", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:17:38Z", "modifyTimestamp": "2020-03-30T12:17:38Z", "actor": "kathy.kane@c42se.com", "directoryId": ["108056515629"], "source": "Box", "url": "https://code42a.box.com/s/sblis4r0zr5p0rbrr87fu3zml8svej58", "shared": "TRUE", "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "9981852168", "detectionSourceAlias": "C42 SE Box", "fileId": "643502901225", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf", "mimeTypeMismatch": false} -{"eventId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk_2_9164694a-48e8-4c89-aed8-36d51d6338d4", "eventType": "CREATED", "eventTimestamp": "2020-03-30T15:29:52.894Z", "insertionTimestamp": "2020-03-31T00:01:48.913Z", "fileName": "9.29 Meeting Notes.txt", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileCategoryByBytes": "Document", "fileCategoryByExtension": "Document", "fileSize": 8089, "fileOwner": "george.washington@c42se.com", "md5Checksum": "86eb5a3c9d0ea6b6c37d3f988f42c718", "sha256Checksum": "4468e007b9b4a8050c10d29b3c9b38ea66d896389b1c27c4d030e129ab0ab688", "createTimestamp": "2020-03-30T15:05:27.880Z", "modifyTimestamp": "2020-03-30T15:05:39.871Z", "actor": "george.washington@c42se.com", "directoryId": ["0AB20OqRQS81NUk9PVA"], "source": "GoogleDrive", "url": "https://drive.google.com/a/c42se.com/file/d/1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk/view?usp=drivesdk", "shared": "TRUE", "sharedWith": [{"cloudUsername": "External (Public)"}], "sharingTypeAdded": ["SharedViaLink"], "cloudDriveId": "0AB20OqRQS81NUk9PVA", "detectionSourceAlias": "C42SE GDrive2", "fileId": "1qsWbkB3KOtSvQELRPTizGN7XuPjlrosk", "exposure": ["SharedViaLink"], "outsideActiveHours": false, "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "text/plain", "mimeTypeMismatch": false} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_213", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.086Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_210", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.079Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_201", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.075Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_199", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.992Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_207", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.037Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_205", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.090Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_204", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.088Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_202", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.077Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_211", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.082Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_200", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.014Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_198", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:44.988Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_208", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.071Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_212", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.084Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_209", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.074Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_206", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.012Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/.testWriteFolder947640216883221892/", "fileName": ".testWriteFile947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_944597031926579042_947640354322175364_203", "eventType": "DELETED", "eventTimestamp": "2020-03-30T16:19:45.080Z", "insertionTimestamp": "2020-03-30T16:21:09.653Z", "filePath": "C:/Users/michelle.goldberg/Desktop/", "fileName": ".testWriteFolder947640216883221892", "fileType": "UNKNOWN", "fileCategory": "UNCATEGORIZED", "deviceUserName": "michelle.goldberg@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.92", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["fe80:0:0:0:29f6:1fed:cdd5:efae%eth2", "172.20.64.92", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "944597031926579042", "userUid": "922302705889597824", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_169", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.759Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-19.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 228687, "fileOwner": "Administrators", "md5Checksum": "9da3457f38edd0e046c933175f46ca24", "sha256Checksum": "1d59d2c941afc4edc177ca6ea4bff0a0ff85b30c3d36498a68c46c157e93ebe5", "createTimestamp": "2019-02-07T18:16:40.414Z", "modifyTimestamp": "2019-02-07T18:16:40.781Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_167", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:18.732Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2019-02-07T18:14:54.402Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_172", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:14.775Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:34:54.617Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_168", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.788Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2019-02-07T18:21:40.645Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_171", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:16.780Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlanning-masterWorkShop-2018.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 888674, "fileOwner": "Administrators", "md5Checksum": "dd0bc4b60d44899ec14fedb3ba6e4ad9", "sha256Checksum": "4855a7290e8c0cb70ce2f12a7bd08ed0238d10176c54b78f79c27e309a56eb10", "createTimestamp": "2019-02-07T18:20:12.547Z", "modifyTimestamp": "2019-02-07T18:20:12.985Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947630530942998643_170", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T14:38:17.727Z", "insertionTimestamp": "2020-03-30T14:43:33.380Z", "filePath": "C:/Users/john.lamonica/Documents/Sales/", "fileName": "SalesPlan-Outline-Dekka-20.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 222829, "fileOwner": "Administrators", "md5Checksum": "de85d81335b089f30c3397e1174781e1", "sha256Checksum": "e049fc0fd048a49a8d0a581cd221af288d2f5882d7b88a88b46611e2037113aa", "createTimestamp": "2020-03-30T14:35:19.754Z", "modifyTimestamp": "2020-03-30T14:35:19.817Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "john.lamonica", "processName": "\\Device\\HarddiskVolume1\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", "windowTitle": ["My Drive - Google Drive - Google Chrome"], "tabUrl": "https://drive.google.com/drive/my-drive", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_674", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:46.083Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlanning-masterWorkShop-2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 884291, "fileOwner": "Administrators", "md5Checksum": "5f1efe84e3a48356b59b44b85ee6d591", "sha256Checksum": "c6a2cc2a63d8a201efe3b0da5dee7598e5adbe25940f9aa77f51b68e01fcaf77", "createTimestamp": "2020-03-30T14:36:45.974Z", "modifyTimestamp": "2020-03-30T14:34:54.711Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_673", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.910Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionB.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 1190765, "fileOwner": "Administrators", "md5Checksum": "cb87c36af66a9c5415537e55a2709151", "sha256Checksum": "a1f9cd847a937d58756a66ee575baa71bb667f646e3e90ed4747ad6704fdd2ee", "createTimestamp": "2020-03-30T14:36:32.692Z", "modifyTimestamp": "2020-03-30T14:34:11.174Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_887085387349988626_947629984072865907_672", "eventType": "CREATED", "eventTimestamp": "2020-03-30T14:36:32.848Z", "insertionTimestamp": "2020-03-30T14:38:08.510Z", "filePath": "C:/Users/john.lamonica/Dropbox/Management/", "fileName": "SalesPlan-HeadcountOptionA.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 298444, "fileOwner": "Administrators", "md5Checksum": "bd53a249fa0ffd99dc59c62ce98edc91", "sha256Checksum": "b9214b4e9ff3a1eabde4d26b8c3654c4dfb09979f095e67a9511192702a0b0e5", "createTimestamp": "2020-03-30T14:36:32.676Z", "modifyTimestamp": "2020-03-30T14:33:26.302Z", "deviceUserName": "john.lamonica@c42se.com", "osHostName": "LAPTOP-008", "domainName": "LAPTOP-008.edu.code42.com", "publicIpAddress": "76.191.118.4", "privateIpAddresses": ["fe80:0:0:0:51b2:636a:3021:f279%eth0", "10.0.1.17", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "887085387349988626", "userUid": "887084403187553910", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "Dropbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_3", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_1", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:07.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_0", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623369524201269_2", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.961Z", "insertionTimestamp": "2020-03-30T13:32:24.325Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_864", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_862", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_860", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_853", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_844", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_826", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "MississippiCloud1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 3897, "fileOwner": "jennifer.vang", "md5Checksum": "d790364577802d43b28e38249a4f01ef", "sha256Checksum": "7e22b9c6c7a19380acd28d699f866a0ee417b57f25b3e4240b95a34951b35685", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_822", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_814", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_811", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_859", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:25.813Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_851", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dragon-pan-_7l2FS4FicM-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10326110, "fileOwner": "jennifer.vang", "md5Checksum": "3a6aad3c9dea5aa2b04a84343270d767", "sha256Checksum": "3e7d1339057b496fe8d395c9cdbd7737a2da76f8d0c850503d175d209b2bb3c9", "createTimestamp": "2020-02-12T12:46:12Z", "modifyTimestamp": "2020-02-12T12:46:12Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_843", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:38.395Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_838", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:44.923Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_837", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_817", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_815", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "paul-hanaoka-a104tlUezug-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2818933, "fileOwner": "jennifer.vang", "md5Checksum": "68cc9c9d063c95303fafbc4a9a8b2d97", "sha256Checksum": "170779a12338948bff1e88aea7fd0c03d90b1c66fcb297f6476b1a4ec0ea82d5", "createTimestamp": "2020-02-12T12:45:52Z", "modifyTimestamp": "2020-02-12T12:45:52Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_813", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_872", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_833", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_830", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_828", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_821", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_819", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_873", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_867", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_852", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:11.204Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_834", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2018-12-10T21:28:34Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_824", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_820", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tyler-casey-R5zkwqHVyYo-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1200088, "fileOwner": "jennifer.vang", "md5Checksum": "f191201157e30d2cb2e5dcfd855406ae", "sha256Checksum": "75a42c5e01fc411b8cd27fd281f2b4e821fe1eb877e768bf7e775d3fefb7e8b6", "createTimestamp": "2020-02-12T12:45:56Z", "modifyTimestamp": "2020-02-12T12:45:56Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_870", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_869", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-12T12:46:26Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_866", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:31.345Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_863", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_850", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_842", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:39.654Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_841", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_839", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_829", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_865", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:30.355Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_849", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_847", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_835", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_871", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.945Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_861", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_856", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-13T16:10:19.557Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_836", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.852Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:53.508Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_818", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "olivia-bauso-8qnHYPEKtU0-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 855061, "fileOwner": "jennifer.vang", "md5Checksum": "b36d3151818730f599d4746bcccdd580", "sha256Checksum": "8fda4c59bdb68a3e28d5e038194901f5c6a8cecc25afa1e16ae1a924db46bdcb", "createTimestamp": "2020-02-13T16:10:29.161Z", "modifyTimestamp": "2020-02-12T12:45:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_858", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-13T16:10:23.312Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_857", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.898Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_855", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_840", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_831", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_827", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321676_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_868", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.930Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_854", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.883Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_848", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_846", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.914Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_845", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.867Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_832", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.836Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321688_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:47.431Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_825", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_823", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.820Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585321675_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_816", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.805Z", "insertionTimestamp": "2020-03-30T13:32:01.477Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_808", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "kelly-sikkema-Z-IRcsILsyc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3557795, "fileOwner": "jennifer.vang", "md5Checksum": "8f9d309c6b0ab3d0a2f4f0a722c6e2cd", "sha256Checksum": "9f8871f43b0e93a5c63006ebb8c774059c9e7a2c8377b386bb924404d02a6202", "createTimestamp": "2020-02-12T12:46:14Z", "modifyTimestamp": "2020-02-12T12:46:14Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_807", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-12T12:46:18Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_804", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-12T12:46:34Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_793", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2018-12-10T21:29:46Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_791", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_790", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_783", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Jaleel CRM Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662547, "fileOwner": "jennifer.vang", "md5Checksum": "71f8aa0fb3c38cad7c53766f59ac01d9", "sha256Checksum": "f554256c3df34efbf700fbcc13f81735602640d853f68e623c40575547ed24f3", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_774", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-12T12:46:50Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_767", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:27.262Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_763", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:15.688Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_760", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-12T12:46:20Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_744", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:50.763Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_801", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_786", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (118 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 6783167, "fileOwner": "jennifer.vang", "md5Checksum": "75f1bfa2a42a759b3c0f56635143dae6", "sha256Checksum": "5b4a5d7dd7fd75e5ce73ad3a53110985bdbde2e1e61361e5b4d6596f3d610af5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_785", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (114 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 10416961, "fileOwner": "jennifer.vang", "md5Checksum": "b816ee1bf58595d8e5cd7a923e3eb8c9", "sha256Checksum": "a66691b862c63895e55079bce5a3a76c0b4863a436953549a802a872fe6bf4a2", "createTimestamp": "2018-12-10T21:30:22Z", "modifyTimestamp": "2018-12-10T21:30:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_756", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "colton-sturgeon-XK76p7lf8Sk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1635294, "fileOwner": "jennifer.vang", "md5Checksum": "fae801951d98eae5f9e011982ac7373c", "sha256Checksum": "f2ba3aad6d7353e15ad008ea86088a84d8a2e29c49e30a7f8b54d283746b0e2c", "createTimestamp": "2020-02-12T12:46:04Z", "modifyTimestamp": "2020-02-12T12:46:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_752", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_745", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2018-12-10T21:28:32Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_803", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jonathan-borba-5Goau2kMWXQ-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2256285, "fileOwner": "jennifer.vang", "md5Checksum": "a5f679654a8919b05f31d6c295c3d3ba", "sha256Checksum": "4e67bebc00c6c36e7a3fa8dce97f2127bcd4f28a82cb5e97d912b9b1f050756c", "createTimestamp": "2020-02-13T16:10:14.666Z", "modifyTimestamp": "2020-02-12T12:46:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_766", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_751", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:40.666Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_742", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_799", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:12.991Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_796", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-12T12:55:04Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_795", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_772", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:12.793Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_769", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:18.105Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_762", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jessica-rockowitz-6c4Uhhe68yQ-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 8577636, "fileOwner": "jennifer.vang", "md5Checksum": "bfaa5878f62630eda0f9efd9dbd2ef08", "sha256Checksum": "0f070182ed4b4596d8a70c755b6b4be8d0a28173d656ca9e7e4b8e1a7d78f024", "createTimestamp": "2020-02-12T12:46:10Z", "modifyTimestamp": "2020-02-12T12:46:10Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_757", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_748", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2018-12-10T21:28:50Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_780", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "JaleelCRM.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4485, "fileOwner": "jennifer.vang", "md5Checksum": "60934cc23c20114be294a45217dcb350", "sha256Checksum": "86dd683dd9bf03ee59d238e120e3e6909179dbd31656b2dbda6e2283bf125891", "createTimestamp": "2020-02-10T02:58:18Z", "modifyTimestamp": "2020-02-10T02:58:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_775", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:10.118Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_770", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:19.425Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_768", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "rafael-silva-zCn9V4RN7hc-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 829370, "fileOwner": "jennifer.vang", "md5Checksum": "d5842ff26f34105f627eb45f17dc435b", "sha256Checksum": "30bc0fd65b9ea9666c12f46f72544a69d13bfe59d867c74cdd8eb20d285eee9c", "createTimestamp": "2020-02-13T16:10:17.161Z", "modifyTimestamp": "2020-02-12T12:46:26Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_765", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_764", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_755", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_743", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_737", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Cooper DB Manual.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 662448, "fileOwner": "jennifer.vang", "md5Checksum": "5b0ed4af0e989bde0339bc19ad61a8c3", "sha256Checksum": "390ec485088c848de1a5f260e220fcc0653651291b66124b80b11c14f9e6ff65", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_735", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "JaleelCRM2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 4497, "fileOwner": "jennifer.vang", "md5Checksum": "741ef1acf2071b0f60d8487677f68e16", "sha256Checksum": "bc5b8e0924de3b4b143ac35201b85393015172cb8e894c879cf14d409669cc21", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_806", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:21.987Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_802", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_798", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "dollar-gill-MOqAfi6GvVU-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3441944, "fileOwner": "jennifer.vang", "md5Checksum": "cfad8522a5aeba2e839e55796e94301b", "sha256Checksum": "d11997310c0d3c4072f1ef69eb635195957368cd8e5e2ba42611fc15449a1caf", "createTimestamp": "2020-02-12T12:46:06Z", "modifyTimestamp": "2020-02-12T12:46:06Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_794", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_792", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (84 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 17555431, "fileOwner": "jennifer.vang", "md5Checksum": "c0b59fc535ae7f0ebd0f8b082821ffd9", "sha256Checksum": "7feddd5f33cd2ded517eea03b98cae4b344270bbeabf8ebde33e650ea4102271", "createTimestamp": "2020-02-13T16:10:43.072Z", "modifyTimestamp": "2018-12-10T21:29:46Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_788", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:49.703Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_771", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:10.627Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_749", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:42.528Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_739", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.06.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407551, "fileOwner": "jennifer.vang", "md5Checksum": "72d06b6a958228513904082c644e0902", "sha256Checksum": "2cc02f5b08f626c9390851edbac05829d154e640635e49b6fb64b7f2647ffc61", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_805", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.773Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "jove-duero-kf3dLxBql6U-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2078103, "fileOwner": "jennifer.vang", "md5Checksum": "24a2bbe57f13a25307eedd56190d279a", "sha256Checksum": "15743feeca29cfa28c9fc6e1196353d8be04d8822da853f08bf599cf1424d867", "createTimestamp": "2020-02-13T16:10:20.918Z", "modifyTimestamp": "2020-02-12T12:46:18Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_784", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "Mississippi Cloud Setup Guide.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 666424, "fileOwner": "jennifer.vang", "md5Checksum": "5149313ac532abe37a44441c63576ad2", "sha256Checksum": "15b7295e2243b0595e5c78a43b075d7531990d4837d92293b1c7386d4d30a3f7", "createTimestamp": "2020-02-10T02:58:24Z", "modifyTimestamp": "2020-02-10T02:58:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_778", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "BlackHornetStoryboard.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 1893, "fileOwner": "jennifer.vang", "md5Checksum": "3732d8580df54e63269e397eeba3de7d", "sha256Checksum": "b11ebfa2c83029db1929a18a2dbaf47fe1abda8c7af72882dcc3339d901ec958", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_776", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-13T16:10:12.714Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_773", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "tim-mossholder-crokFj1jXVU-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4871148, "fileOwner": "jennifer.vang", "md5Checksum": "af7a4219049b0a8cf14b43946d565382", "sha256Checksum": "ad5395e4c61b1d81a40f8952f2892d3cc49af6e66429ecc7d9357d444c06abc7", "createTimestamp": "2020-02-13T16:10:14.293Z", "modifyTimestamp": "2020-02-12T12:46:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_758", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-12T12:46:42Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_753", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:06.552Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_747", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:49.625Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_746", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.633Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (55 of 133) (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 13485401, "fileOwner": "jennifer.vang", "md5Checksum": "0d18e4f3788d6b104bc2440033752107", "sha256Checksum": "f9b6fc1eab4661f671795ee49aabb482302a8cd4a2119e7949db7ab2e2c97b69", "createTimestamp": "2020-02-13T16:10:47.368Z", "modifyTimestamp": "2018-12-10T21:28:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_738", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_736", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "MississippiCloud3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2657, "fileOwner": "jennifer.vang", "md5Checksum": "2b732f159cdaff64fee6bf922f4e6901", "sha256Checksum": "5ae69936410f58eaf5f290d753ba1e59daee654ea7035c30d665dbb7e469febc", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "nathan-dumlao-Xavq7lKj5j8-unsplash (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1273064, "fileOwner": "jennifer.vang", "md5Checksum": "e537aa982652e68539f860d68047dad9", "sha256Checksum": "b99cc6bcfafc285bcb620ebbb5a24f59933fbe4787748e7dba8fd239a27fbf1e", "createTimestamp": "2020-02-13T16:10:25.985Z", "modifyTimestamp": "2020-02-12T12:46:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_797", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "artem-beliaikin-6V2MuXdD_BI-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4853643, "fileOwner": "jennifer.vang", "md5Checksum": "e1743b2b1fd1a04a041dcf5d2daf3c94", "sha256Checksum": "ff2047237905c6a4496ba8361252c7adc88ff13a8a80894d4c4fccc680741d07", "createTimestamp": "2020-02-12T12:45:50Z", "modifyTimestamp": "2020-02-12T12:45:50Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_789", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.742Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (45 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3705121, "fileOwner": "jennifer.vang", "md5Checksum": "bfd5a13e6cbe3633212273a2a3aee4f7", "sha256Checksum": "3f75f1c8af985de3f1e7c0930bc8dddd193da91918505dad2e479982bddf27ac", "createTimestamp": "2020-02-13T16:10:49.798Z", "modifyTimestamp": "2018-12-10T21:28:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_777", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.695Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "zane-lee-9hrhtTlv2og-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 4530543, "fileOwner": "jennifer.vang", "md5Checksum": "9f25487b990389d917ec4355161a1835", "sha256Checksum": "40acd646d27c1cf5cc3fe3e22b9d1ec45ae44d53405c5baa8e51ba538cba68c4", "createTimestamp": "2020-02-12T12:47:00Z", "modifyTimestamp": "2020-02-12T12:47:00Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_754", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.680Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "Lake.Powell (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 1467733, "fileOwner": "jennifer.vang", "md5Checksum": "9413d8a279fa9a9cc201f3d487f612c2", "sha256Checksum": "9b121a2c12086d968eeb962b4bebba5c133229123a291ee4d8b8a8fa71b38ccf", "createTimestamp": "2020-02-13T16:10:07.526Z", "modifyTimestamp": "2020-02-12T12:55:04Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_740", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157083_documentation and notes/", "fileName": "Mississippi Cloud Charter.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407550, "fileOwner": "jennifer.vang", "md5Checksum": "cf3de0ac1511ee3a78bde57debd9b91f", "sha256Checksum": "3cdcd42c63080ed97aaa05f371a87976330d832273393c293b4511e223894ab7", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_733", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB2.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2133, "fileOwner": "jennifer.vang", "md5Checksum": "9a10e96d9988c16fb2b9b9464741d072", "sha256Checksum": "0284700f08ebd7989607b6b5dd7df6577d2ac706265ba03b46443f8777b989ee", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_809", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.789Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "milad-shams-PBdgd1hq-ZA-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 2973975, "fileOwner": "jennifer.vang", "md5Checksum": "df2b48a29157ad27a2473b030e4006d5", "sha256Checksum": "d1c2d8c1d53273e07e2a35b0faaa5ec60b82bf3cd82c9e14cc2eb5de6afa93cf", "createTimestamp": "2020-02-12T12:46:32Z", "modifyTimestamp": "2020-02-12T12:46:32Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_800", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.758Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "gabriel-cunha-qVyf3TnLmBk-unsplash (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 383008, "fileOwner": "jennifer.vang", "md5Checksum": "2066ae96b7c6aa0c17f5b382ec4cfb54", "sha256Checksum": "7da6354eaf9b89fdd11260335c9d36d214e03c016aee340c583ff6575c8a3257", "createTimestamp": "2020-02-13T16:10:14.455Z", "modifyTimestamp": "2020-02-12T12:46:42Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_787", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.727Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255453_family photos/", "fileName": "18.09.MN.State.Fair (119 of 133).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 11786180, "fileOwner": "jennifer.vang", "md5Checksum": "5fa18acf4fc97eb4bf8632a60b956a6c", "sha256Checksum": "4228073c4aee56559d664c0f35c02d7bea17809dc9a4be7efa890ad7e49a81a5", "createTimestamp": "2018-12-10T21:30:28Z", "modifyTimestamp": "2018-12-10T21:30:28Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_782", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255445_documentation and notes/", "fileName": "CooperDB Planning Notes 02.02.docx", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 407569, "fileOwner": "jennifer.vang", "md5Checksum": "687d09b2ccc2a5e91565d82e194b7044", "sha256Checksum": "85060c93c5cf2e259945f0a600645b712af1995549725e206cc1ac8232069045", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_781", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "Longfellow North Campus Network Diagram.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 6733, "fileOwner": "jennifer.vang", "md5Checksum": "0df46da580a4acb02e9e509da7e2ec32", "sha256Checksum": "8605a5edb90daeef9047f99bbd676de3c51b59d19ff44eefe4b4d1f89674c24e", "createTimestamp": "2020-02-10T02:58:20Z", "modifyTimestamp": "2020-02-10T02:58:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_779", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.711Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585255444_Design/", "fileName": "CoopDB1.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2129, "fileOwner": "jennifer.vang", "md5Checksum": "8d3b15ccd8c4af0cefe8a632065052ab", "sha256Checksum": "05b32e286b103b97b0efeb8016655b94a71c0b6ccace1aa434935104c7990dcd", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_761", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "guillaume-m-9B4BRGkEiFc-unsplash.jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 575474, "fileOwner": "jennifer.vang", "md5Checksum": "c19403c72d121c043e6df9f6851ec4b1", "sha256Checksum": "2655ac63984ca79afb4bdc6429e7d4d1cb37866e8b91fe991e48c64dd77e378b", "createTimestamp": "2020-02-12T12:46:24Z", "modifyTimestamp": "2020-02-12T12:46:24Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_759", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.664Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "gabriel-silverio-M74CmExcCL0-unsplash (2).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 3312225, "fileOwner": "jennifer.vang", "md5Checksum": "2e24e8615eda3650ab9297223ca98313", "sha256Checksum": "b9646f9cd2eb8cccb796d7e91d4f2cad43e81fbd74cd120e26bcf87c7226efb5", "createTimestamp": "2020-02-13T16:10:20.824Z", "modifyTimestamp": "2020-02-12T12:46:20Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_750", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.648Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (80 of 133) (3).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 12105250, "fileOwner": "jennifer.vang", "md5Checksum": "862f627c1894c8bd5da882fb8f400fdc", "sha256Checksum": "3b8338cf9b0292a5de4316025a4ab3837e8f214137267e9963401d8af878e3bd", "createTimestamp": "2020-02-13T16:10:44.240Z", "modifyTimestamp": "2018-12-10T21:29:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_741", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.617Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157093_family photos/", "fileName": "18.09.MN.State.Fair (43 of 133) (1).jpg", "fileType": "FILE", "fileCategory": "IMAGE", "fileSize": 15660747, "fileOwner": "jennifer.vang", "md5Checksum": "b5c0a5c64cae7674fabe9d3f767a00e9", "sha256Checksum": "744c28933e021364aa682122016f3959dda80f4ccbcca0c61b162cdd2b741c78", "createTimestamp": "2020-02-13T16:10:45.628Z", "modifyTimestamp": "2018-12-10T21:28:34Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "image/jpeg", "mimeTypeByExtension": "image/jpeg"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941718878859757091_947623328671680309_734", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:08.602Z", "insertionTimestamp": "2020-03-30T13:32:01.476Z", "filePath": "C:/Users/jennifer.vang/Google Drive/1585157082_Design/", "fileName": "CoopDB3.drawio", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 2157, "fileOwner": "jennifer.vang", "md5Checksum": "7b10033250f0866b5066fd12875c9528", "sha256Checksum": "688e2918e4c40279b764bfd1075e99152e92da000e889441f1ad9e443b664951", "createTimestamp": "2020-02-10T02:58:22Z", "modifyTimestamp": "2020-02-10T02:58:22Z", "deviceUserName": "jennifer.vang@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.52", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.52", "0:0:0:0:0:0:0:1", "fe80:0:0:0:48db:74b0:3b12:e42c%eth2", "127.0.0.1"], "deviceUid": "941718878859757091", "userUid": "941708482782649567", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "GoogleBackupAndSync", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_941983451917189059_947621656901949516_346", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T13:10:15.756Z", "insertionTimestamp": "2020-03-30T13:15:24.556Z", "filePath": "C:/Users/darnell.waters/OneDrive - Code42/", "fileName": ".849C9593-D756-4E56-8D6E-42412F2A707B", "fileType": "FILE", "fileCategory": "DOCUMENT", "fileSize": 63, "fileOwner": "darnell.waters", "md5Checksum": "e37ee15a01960b22e4ece7f055532215", "sha256Checksum": "002d0c0a9f80d3bb5df04547e533553d4046d008bb88807627801157276b535c", "createTimestamp": "2020-02-19T21:48:30.549Z", "modifyTimestamp": "2020-03-30T12:51:09.790Z", "deviceUserName": "darnell.waters@c42se.com", "osHostName": "W10E-X64-FALLCR", "domainName": "172.20.64.39", "publicIpAddress": "162.222.47.183", "privateIpAddresses": ["172.20.64.39", "fe80:0:0:0:1d77:dcdf:c593:1143%eth2", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "941983451917189059", "userUid": "902428473202283166", "source": "Endpoint", "exposure": ["CloudStorage"], "syncDestination": "OneDrive", "mimeTypeByBytes": "text/plain", "mimeTypeByExtension": "application/octet-stream"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_731", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:03.757Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CRM Report - Inscents.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 32346, "fileOwner": "kathy.kane", "md5Checksum": "6ec589b2e49feebe91b29c447c34fd99", "sha256Checksum": "b9fd589c001b4e8d96d2238e42412f80e039456d91f42fefebdfd055ed56504a", "createTimestamp": "2020-03-30T12:17:14.785Z", "modifyTimestamp": "2020-03-30T12:17:18.098Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/zip", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942937660159132797_947616065892775583_730", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T12:18:08.695Z", "insertionTimestamp": "2020-03-30T12:19:53.275Z", "filePath": "C:/Users/kathy.kane/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "kathy.kane", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:14:10.543Z", "modifyTimestamp": "2020-03-30T12:14:12.757Z", "deviceUserName": "kathy.kane@c42se.com", "osHostName": "LAPTOP-033", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:ecd4:59c8:7a21:42dc%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "942937660159132797", "userUid": "886897886179661430", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "kathy.kane", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Sales Docs | Powered by Box - Mozilla Firefox"], "tabUrl": "https://code42a.app.box.com/folder/108056515629", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_810", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.430Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T11:53:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_811", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.461Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_809", "eventType": "CREATED", "eventTimestamp": "2020-03-30T12:02:48.368Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "Everyone", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T12:02:47.440Z", "modifyTimestamp": "2020-03-30T12:02:48Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/octet-stream", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_947614626223670968_812", "eventType": "MODIFIED", "eventTimestamp": "2020-03-30T12:02:48.492Z", "insertionTimestamp": "2020-03-30T12:05:35.651Z", "filePath": "F:/", "fileName": "Longfellow Sec Ongoing Investigations.xlsx", "fileType": "FILE", "fileCategory": "SPREADSHEET", "fileSize": 13526, "fileOwner": "Everyone", "md5Checksum": "ee6818ec173463ccb2efca3b351b928e", "sha256Checksum": "fe625a6ef00b2d59d276fc2de6fa815acf56cb3048a15616c7dee9b6e623cce6", "createTimestamp": "2020-03-30T12:02:47.470Z", "modifyTimestamp": "2020-03-30T11:59:58Z", "deviceUserName": "sean.cassidy@c42se.com", "osHostName": "LAPTOP-091", "domainName": "192.168.65.129", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", "192.168.65.129", "0:0:0:0:0:0:0:1", "127.0.0.1"], "deviceUid": "942704829036142720", "userUid": "887050325252344565", "source": "Endpoint", "exposure": ["RemovableMedia"], "removableMediaVendor": "Kingston", "removableMediaName": "DataTraveler 3.0", "removableMediaSerialNumber": "6E0FA4404DC9", "removableMediaCapacity": 15614803968, "removableMediaBusType": "USB", "removableMediaMediaName": "Kingston DataTraveler 3.0 Media", "removableMediaVolumeName": ["KINGSTON (F:)"], "removableMediaPartitionId": ["a3e213e5-0000-0000-0000-3f0000000000"], "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "mimeTypeByExtension": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947613460610701533_502", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:03.888Z", "insertionTimestamp": "2020-03-30T11:53:59.251Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "SAC_Book_SecurityAwarenessPlaybook.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 3125834, "fileOwner": "jordan.anderson", "md5Checksum": "af3a63b4bbe732f1b7f17694e1762de8", "sha256Checksum": "7c558f359788befa3700e3c901caeb738ebc2475803cc347bebf42a692ee8724", "createTimestamp": "2019-05-22T17:17:57.425Z", "modifyTimestamp": "2019-05-22T17:17:59.481Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612912599718109_334", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:48:00.980Z", "insertionTimestamp": "2020-03-30T11:48:34.115Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "N-SOS-022_TheGlobalCostOfInsecurity.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 562441, "fileOwner": "jordan.anderson", "md5Checksum": "9d5b6ced2937c1bac8231e259560f0d4", "sha256Checksum": "0b3660bccde1197d521ca10d532f5e978ca31e78552466842c8c74e0fe5012fa", "createTimestamp": "2019-05-22T17:18:05.266Z", "modifyTimestamp": "2019-05-22T17:18:06.653Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["InfoSec - Google Drive - Mozilla Firefox"], "tabUrl": "https://drive.google.com/drive/folders/0ABWU7KYD-MfpUk9PVA", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} -{"eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_886765628300556950_947612023390241449_126", "eventType": "READ_BY_APP", "eventTimestamp": "2020-03-30T11:36:55.028Z", "insertionTimestamp": "2020-03-30T11:39:43.734Z", "filePath": "C:/Users/jordan.anderson/Downloads/", "fileName": "CONFIDENTIAL Pentest Assessment Q1 2020.pdf", "fileType": "FILE", "fileCategory": "PDF", "fileSize": 56653, "fileOwner": "jordan.anderson", "md5Checksum": "03ccb475afc4f92aa9fc4efda0ce353b", "sha256Checksum": "e643239c53dc190cbdf7d5ba8f60e2311daf32a0c0593bfcd0be6b3a89202295", "createTimestamp": "2020-03-30T11:20:50.858Z", "modifyTimestamp": "2020-03-30T11:20:55.671Z", "deviceUserName": "jordan.anderson@c42se.com", "osHostName": "JANDERSON-LT02", "domainName": "192.168.65.130", "publicIpAddress": "71.34.10.80", "privateIpAddresses": ["192.168.65.130", "fe80:0:0:0:f8e7:295a:b339:fe67%eth0", "0:0:0:0:0:0:0:1", "127.0.0.1", "fe80:0:0:0:e92c:dc74:734e:6dea%eth2"], "deviceUid": "886765628300556950", "userUid": "886765398677810428", "source": "Endpoint", "exposure": ["ApplicationRead"], "processOwner": "jordan.anderson", "processName": "\\Device\\HarddiskVolume1\\Program Files\\Mozilla Firefox\\firefox.exe", "windowTitle": ["Inbox (9) - jordan.anderson@c42se.com - Code42 SE Mail - Mozilla Firefox"], "tabUrl": "https://mail.google.com/mail/u/0/#inbox", "mimeTypeByBytes": "application/pdf", "mimeTypeByExtension": "application/pdf"} diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py index 15e2d0746..80711df10 100644 --- a/tests/cmds/detectionlists/test_departing_employee.py +++ b/tests/cmds/detectionlists/test_departing_employee.py @@ -1,4 +1,5 @@ import pytest +import logging from code42cli.cmds.detectionlists import UserDoesNotExistError from code42cli.cmds.detectionlists.departing_employee import ( @@ -36,13 +37,13 @@ def test_add_departing_employee_when_user_does_not_exist_exits(sdk_without_user, def test_add_departing_employee_when_user_does_not_exist_prints_error( - sdk_without_user, profile, capsys + sdk_without_user, profile, caplog ): - try: - add_departing_employee(sdk_without_user, profile, _EMPLOYEE) - except UserDoesNotExistError: - capture = capsys.readouterr() - assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out + with caplog.at_level(logging.ERROR): + try: + add_departing_employee(sdk_without_user, profile, _EMPLOYEE) + except UserDoesNotExistError: + assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text def test_remove_departing_employee_calls_remove(sdk_with_user, profile): @@ -56,10 +57,10 @@ def test_remove_departing_employee_when_user_does_not_exist_exits(sdk_without_us def test_remove_departing_employee_when_user_does_not_exist_prints_error( - sdk_without_user, profile, capsys + sdk_without_user, profile, caplog ): - try: - remove_departing_employee(sdk_without_user, profile, _EMPLOYEE) - except UserDoesNotExistError: - capture = capsys.readouterr() - assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out + with caplog.at_level(logging.ERROR): + try: + remove_departing_employee(sdk_without_user, profile, _EMPLOYEE) + except UserDoesNotExistError: + assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index baae317d3..2acfc87cd 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -1,4 +1,5 @@ import pytest +import logging from code42cli.cmds.detectionlists import UserDoesNotExistError from code42cli.cmds.detectionlists.high_risk_employee import ( @@ -49,13 +50,13 @@ def test_add_high_risk_employee_when_user_does_not_exist_exits(sdk_without_user, def test_add_high_risk_employee_when_user_does_not_exist_prints_error( - sdk_without_user, profile, capsys + sdk_without_user, profile, caplog ): - try: - add_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) - except UserDoesNotExistError: - capture = capsys.readouterr() - assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out + with caplog.at_level(logging.ERROR): + try: + add_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) + except UserDoesNotExistError: + assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text def test_remove_high_risk_employee_calls_remove(sdk_with_user, profile): @@ -69,13 +70,13 @@ def test_remove_high_risk_employee_when_user_does_not_exist_exits(sdk_without_us def test_remove_high_risk_employee_when_user_does_not_exist_prints_error( - sdk_without_user, profile, capsys + sdk_without_user, profile, caplog ): - try: - remove_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) - except UserDoesNotExistError: - capture = capsys.readouterr() - assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out + with caplog.at_level(logging.ERROR): + try: + remove_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) + except UserDoesNotExistError: + assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text def test_add_risk_tags_adds_tags(sdk_with_user, profile): @@ -97,12 +98,14 @@ def test_add_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile) add_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) -def test_add_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, capsys): - try: - add_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) - except UserDoesNotExistError: - capture = capsys.readouterr() - assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out +def test_add_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, caplog): + with caplog.at_level(logging.ERROR): + try: + add_risk_tags( + sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + ) + except UserDoesNotExistError: + assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text def test_remove_risk_tags_adds_tags(sdk_with_user, profile): @@ -124,9 +127,11 @@ def test_remove_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profi remove_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) -def test_remove_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, capsys): - try: - remove_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) - except UserDoesNotExistError: - capture = capsys.readouterr() - assert str(UserDoesNotExistError(_EMPLOYEE)) in capture.out +def test_remove_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, caplog): + with caplog.at_level(logging.ERROR): + try: + remove_risk_tags( + sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + ) + except UserDoesNotExistError: + assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 25f9493b3..07c8b0106 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -1,4 +1,5 @@ import pytest +import logging from code42cli import PRODUCT_NAME from code42cli.cmds.detectionlists import ( @@ -11,6 +12,7 @@ from code42cli.cmds.detectionlists.enums import BulkCommandType from .conftest import TEST_ID + _NAMESPACE = "{}.cmds.detectionlists".format(PRODUCT_NAME) @@ -29,12 +31,12 @@ def test_get_user_id_when_user_does_not_raise_error(sdk_without_user): get_user_id(sdk_without_user, "risky employee") -def test_get_user_id_when_user_does_not_exist_prints_error(sdk_without_user, capsys): - try: - get_user_id(sdk_without_user, "risky employee") - except UserDoesNotExistError: - capture = capsys.readouterr() - assert "ERROR: User 'risky employee' does not exist." in capture.out +def test_get_user_id_when_user_does_not_exist_logs_error(sdk_without_user, caplog): + with caplog.at_level(logging.ERROR): + try: + get_user_id(sdk_without_user, "risky employee") + except UserDoesNotExistError: + assert "ERROR: User 'risky employee' does not exist." in caplog.text def test_update_user_adds_cloud_alias(sdk_with_user, profile): diff --git a/tests/cmds/securitydata/test_cursor_store.py b/tests/cmds/securitydata/test_cursor_store.py index 6c5cee92a..4a4132cf8 100644 --- a/tests/cmds/securitydata/test_cursor_store.py +++ b/tests/cmds/securitydata/test_cursor_store.py @@ -1,7 +1,5 @@ from os import path -from c42eventextractor.extractors import INSERTION_TIMESTAMP_FIELD_NAME - from code42cli import PRODUCT_NAME from code42cli.cmds.shared.cursor_store import BaseCursorStore, FileEventCursorStore @@ -43,7 +41,7 @@ def test_get_stored_insertion_timestamp_executes_expected_select_query(self, sql store.get_stored_insertion_timestamp() with store._connection as conn: expected = "SELECT {0} FROM file_event_checkpoints WHERE cursor_id=?".format( - INSERTION_TIMESTAMP_FIELD_NAME + u"insertionTimestamp" ) actual = conn.cursor().execute.call_args[0][0] assert actual == expected @@ -65,7 +63,7 @@ def test_replace_stored_insertion_timestamp_executes_expected_update_query( store.replace_stored_insertion_timestamp(123) with store._connection as conn: expected = "UPDATE file_event_checkpoints SET {0}=? WHERE cursor_id=?".format( - INSERTION_TIMESTAMP_FIELD_NAME + u"insertionTimestamp" ) actual = conn.execute.call_args[0][0] assert actual == expected diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index 98c0cd594..9ca99b775 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -1,4 +1,6 @@ import pytest +import logging + from py42.sdk import SDKClient from py42.sdk.queries.fileevents.filters import * @@ -6,8 +8,8 @@ import code42cli.errors as errors from code42cli import PRODUCT_NAME from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions -from .conftest import SECURITYDATA_NAMESPACE, get_filter_value_from_json -from ...conftest import get_test_date_str, begin_date_str +from .conftest import get_filter_value_from_json +from ...conftest import get_test_date_str, begin_date_str, ErrorTrackerTestHelper @pytest.fixture @@ -23,20 +25,10 @@ def mock_42(mocker): @pytest.fixture def logger(mocker): mock = mocker.MagicMock() - mock.info = mocker.MagicMock() + mock.print_info = mocker.MagicMock() return mock -@pytest.fixture(autouse=True) -def error_logger(mocker): - return mocker.patch("{0}.extraction.get_error_logger".format(SECURITYDATA_NAMESPACE)) - - -@pytest.fixture -def error_printer(mocker): - return mocker.patch("{}.cmds.securitydata.extraction.print_error".format(PRODUCT_NAME)) - - @pytest.fixture def extractor(mocker): mock = mocker.MagicMock() @@ -53,23 +45,6 @@ def namespace_with_begin(namespace): return namespace -@pytest.fixture -def is_interactive_function(mocker): - return mocker.patch("{}.cmds.securitydata.extraction.is_interactive".format(PRODUCT_NAME)) - - -@pytest.fixture -def interactive_mode(is_interactive_function): - is_interactive_function.return_value = True - return is_interactive_function - - -@pytest.fixture -def non_interactive_mode(is_interactive_function): - is_interactive_function.return_value = False - return is_interactive_function - - @pytest.fixture def checkpoint(mocker): return mocker.patch( @@ -330,7 +305,7 @@ def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_curs assert not filter_term_is_in_call_args(extractor, EventTimestamp._term) -def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( +def test_when_given_begin_date_and_not_incremental_mode_and_cursor_exists_uses_begin_date( sdk, profile, logger, namespace, extractor ): namespace.begin = get_test_date_str(days_ago=1) @@ -511,57 +486,36 @@ def side_effect(): extraction_module.extract(sdk, profile, logger, namespace) -def test_extract_when_errored_and_is_interactive_prints_error( - mocker, sdk, profile, logger, namespace_with_begin, extractor +def test_extract_when_errored_logs_error_occurred( + sdk, profile, logger, namespace_with_begin, extractor, caplog ): - errors.ERRORED = False - errors_error_printer = mocker.patch("{}.errors.print_error".format(PRODUCT_NAME)) - errors_interactive_mode = mocker.patch("{}.errors.is_interactive".format(PRODUCT_NAME)) - errors_interactive_mode.return_value = True - errors.ERRORED = True - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert errors_error_printer.call_count - errors.ERRORED = False + with ErrorTrackerTestHelper(): + with caplog.at_level(logging.ERROR): + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert "ERROR" in caplog.text + assert "View exceptions that occurred at" in caplog.text -def test_extract_when_errored_and_is_not_interactive_does_not_print_error( - sdk, profile, logger, namespace_with_begin, extractor, error_printer, non_interactive_mode +def test_extract_when_not_errored_and_does_not_log_error_occurred( + sdk, profile, logger, namespace_with_begin, extractor, caplog ): - errors.ERRORED = True extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert not error_printer.call_count - errors.ERRORED = False + with caplog.at_level(logging.ERROR): + assert "View exceptions that occurred at" not in caplog.text -def test_extract_when_not_errored_and_is_interactive_does_not_print_error( - sdk, profile, logger, namespace_with_begin, extractor, error_printer, interactive_mode +def test_when_handle_event_raises_exception_global_variable_gets_set( + mocker, sdk, extractor, profile, logger, namespace_with_begin, mock_42 ): - errors.ERRORED = False - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert not error_printer.call_count - errors.ERRORED = False - - -def test_when_sdk_raises_exception_global_variable_gets_set( - mocker, sdk, profile, logger, namespace_with_begin, mock_42 -): - errors.ERRORED = False mock_sdk = mocker.MagicMock() - # For ease - mock = mocker.patch("{}.cmds.securitydata.extraction.is_interactive".format(PRODUCT_NAME)) - mock.return_value = False - def sdk_side_effect(self, *args): raise Exception() mock_sdk.security.search_file_events.side_effect = sdk_side_effect mock_42.return_value = mock_sdk - mocker.patch( - "c42eventextractor.extractors.FileEventExtractor._verify_compatibility_of_filter_groups" - ) - - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert errors.ERRORED - errors.ERRORED = False + mocker.patch("c42eventextractor.extractors.BaseExtractor._verify_filter_groups") + with ErrorTrackerTestHelper(): + extraction_module.extract(sdk, profile, logger, namespace_with_begin) + assert errors.ERRORED diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index d730b7729..d291d5b4d 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,4 +1,5 @@ import pytest +import logging import code42cli.cmds.profile as profilecmd from code42cli import PRODUCT_NAME @@ -47,18 +48,17 @@ def invalid_connection(mock_verify): return mock_verify -def test_show_profile_outputs_profile_info(capsys, mock_cliprofile_namespace, profile): +def test_show_profile_outputs_profile_info(caplog, mock_cliprofile_namespace, profile): profile.name = "testname" profile.authority_url = "example.com" profile.username = "foo" profile.disable_ssl_errors = True mock_cliprofile_namespace.get_profile.return_value = profile profilecmd.show_profile(profile) - capture = capsys.readouterr() - assert "testname" in capture.out - assert "example.com" in capture.out - assert "foo" in capture.out - assert "A password is set" in capture.out + assert "testname" in caplog.text + assert "example.com" in caplog.text + assert "foo" in caplog.text + assert "A password is set" in caplog.text def test_show_profile_when_password_set_outputs_password_note( @@ -116,6 +116,15 @@ def test_create_profile_if_credentials_valid_password_saved( mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) +def test_create_profile_outputs_confirmation( + user_agreement, valid_connection, mock_cliprofile_namespace, caplog +): + with caplog.at_level(logging.INFO): + mock_cliprofile_namespace.profile_exists.return_value = False + profilecmd.create_profile("foo", "bar", "baz", True) + assert "Successfully created profile 'foo'." in caplog.text + + def test_update_profile_updates_existing_profile( mock_cliprofile_namespace, user_agreement, valid_connection, profile ): @@ -164,24 +173,12 @@ def test_update_profile_if_user_agrees_and_valid_connection_sets_password( def test_delete_profile_warns_if_deleting_default( - capsys, user_agreement, mock_cliprofile_namespace + caplog, user_agreement, mock_cliprofile_namespace ): mock_cliprofile_namespace.is_default_profile.return_value = True - profilecmd.delete_profile("mockdefault") - capture = capsys.readouterr() - assert "mockdefault is currently the default profile!" in capture.out - - -def test_delete_all_warns_if_profiles_exist(capsys, user_agreement, mock_cliprofile_namespace): - mock_cliprofile_namespace.get_all_profiles.return_value = [ - create_mock_profile("test1"), - create_mock_profile("test2"), - ] - profilecmd.delete_all_profiles() - capture = capsys.readouterr() - assert "Are you sure you want to delete the following profiles?" in capture.out - assert "test1" in capture.out - assert "test2" in capture.out + with caplog.at_level(logging.ERROR): + profilecmd.delete_profile("mockdefault") + assert "mockdefault is currently the default profile!" in caplog.text def test_delete_profile_does_nothing_if_user_doesnt_agree( @@ -191,6 +188,24 @@ def test_delete_profile_does_nothing_if_user_doesnt_agree( assert mock_cliprofile_namespace.delete_profile.call_count == 0 +def test_delete_profile_outputs_success(user_agreement, mock_cliprofile_namespace, caplog): + with caplog.at_level(logging.INFO): + profilecmd.delete_profile("mockprofile") + assert "Profile 'mockProfile' has been deleted." + + +def test_delete_all_warns_if_profiles_exist(caplog, user_agreement, mock_cliprofile_namespace): + mock_cliprofile_namespace.get_all_profiles.return_value = [ + create_mock_profile("test1"), + create_mock_profile("test2"), + ] + with caplog.at_level(logging.INFO): + profilecmd.delete_all_profiles() + assert "Are you sure you want to delete the following profiles?" in caplog.text + assert "test1" in caplog.text + assert "test2" in caplog.text + + def test_delete_all_profiles_does_nothing_if_user_doesnt_agree( user_disagreement, mock_cliprofile_namespace ): @@ -231,27 +246,27 @@ def test_prompt_for_password_reset_if_credentials_invalid_password_not_saved( assert success -def test_list_profiles(capsys, mock_cliprofile_namespace): +def test_list_profiles(caplog, mock_cliprofile_namespace): profiles = [ create_mock_profile("one"), create_mock_profile("two"), create_mock_profile("three"), ] mock_cliprofile_namespace.get_all_profiles.return_value = profiles - profilecmd.list_profiles() - capture = capsys.readouterr() - assert "one" in capture.out - assert "two" in capture.out - assert "three" in capture.out + with caplog.at_level(logging.INFO): + profilecmd.list_profiles() + assert "one" in caplog.text + assert "two" in caplog.text + assert "three" in caplog.text def test_list_profiles_when_no_profiles_outputs_no_profiles_message( - capsys, mock_cliprofile_namespace + caplog, mock_cliprofile_namespace ): mock_cliprofile_namespace.get_all_profiles.return_value = [] profilecmd.list_profiles() - capture = capsys.readouterr() - assert "No existing profile." in capture.out + with caplog.at_level(logging.ERROR): + assert "No existing profile." in caplog.text def test_use_profile(mock_cliprofile_namespace, profile): diff --git a/tests/conftest.py b/tests/conftest.py index f484f6888..7c30e8cda 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,8 @@ from code42cli.config import ConfigAccessor from code42cli.profile import Code42Profile +import code42cli.errors as error_tracker + @pytest.fixture def namespace(mocker): @@ -130,3 +132,11 @@ def get_test_date_str(days_ago): begin_date_with_time = [get_test_date_str(days_ago=89), "3:12:33"] end_date_str = get_test_date_str(days_ago=10) end_date_with_time = [get_test_date_str(days_ago=10), "11:22:43"] + + +class ErrorTrackerTestHelper: + def __enter__(self): + error_tracker.ERRORED = True + + def __exit__(self, exc_type, exc_val, exc_tb): + error_tracker.ERRORED = False diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 1ae612340..0f583f365 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -1,10 +1,15 @@ from collections import OrderedDict from io import IOBase import pytest +import logging from code42cli import PRODUCT_NAME from code42cli import errors as errors from code42cli.bulk import generate_template, BulkProcessor, run_bulk_process, CSVReader +from code42cli.logger import get_view_exceptions_location_message + +from .conftest import ErrorTrackerTestHelper + _NAMESPACE = "{}.bulk".format(PRODUCT_NAME) @@ -54,13 +59,13 @@ def test_generate_template_when_handler_has_one_arg_creates_file_without_columns assert not template_file.write.call_count -def test_generate_template_when_handler_has_one_arg_prints_message(mock_open, capsys): - generate_template(func_with_one_arg, "some/path") - capture = capsys.readouterr() - assert ( - u"A blank file was generated because there are no csv headers needed for this command. " - u"Simply enter one test1 per line." in capture.out - ) +def test_generate_template_when_handler_has_one_arg_prints_message(mock_open, caplog): + with caplog.at_level(logging.INFO): + generate_template(func_with_one_arg, "some/path") + assert ( + u"A blank file was generated because there are no csv headers needed for this command. " + u"Simply enter one test1 per line." in caplog.text + ) def test_generate_template_when_handler_has_more_than_one_arg_does_not_print_message( @@ -95,7 +100,6 @@ def test_run_bulk_process_when_not_given_reader_uses_csv_reader(bulk_processor_f class TestBulkProcessor(object): def test_run_when_reader_returns_ordered_dict_process_kwargs(self, mock_open): - errors.ERRORED = False processed_rows = [] def func_for_bulk(test1, test2): @@ -114,10 +118,8 @@ def __call__(self, *args, **kwargs): assert (1, 2) in processed_rows assert (3, 4) in processed_rows assert (5, 6) in processed_rows - - + def test_run_when_reader_returns_dict_process_kwargs(self, mock_open): - errors.ERRORED = False processed_rows = [] def func_for_bulk(test1, test2): @@ -138,7 +140,6 @@ def __call__(self, *args, **kwargs): assert (5, 6) in processed_rows def test_run_when_dict_reader_has_none_for_key_ignores_key(self, mock_open): - errors.ERRORED = False processed_rows = [] def func_for_bulk(test1): @@ -153,7 +154,6 @@ def __call__(self, *args, **kwargs): assert processed_rows == [1] def test_run_when_reader_returns_strs_processes_strs(self, mock_open): - errors.ERRORED = False processed_rows = [] def func_for_bulk(test): @@ -169,8 +169,8 @@ def __call__(self, *args, **kwargs): assert "row2" in processed_rows assert "row3" in processed_rows - def test_run_when_error_occurs_prints_error_messages(self, mock_open, capsys): - errors.ERRORED = False + def test_run_when_error_occurs_prints_error_messages(self, mock_open, caplog): + caplog.set_level(logging.INFO) def func_for_bulk(test): if test == "row2": @@ -180,16 +180,17 @@ class MockRowReader(object): def __call__(self, *args, **kwargs): return ["row1", "row2", "row3"] - processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) - processor.run() - capture = capsys.readouterr() - assert "2 processed successfully out of 3." in capture.out - assert errors.get_error_message() in capture.out - errors.ERRORED = False + with ErrorTrackerTestHelper(): + processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + processor.run() - def test_run_when_no_errors_occur_prints_success_messages(self, mock_open, capsys): - errors.ERRORED = False + with caplog.at_level(logging.INFO): + assert "2 processed successfully out of 3." in caplog.text + with caplog.at_level(logging.ERROR): + assert get_view_exceptions_location_message() in caplog.text + + def test_run_when_no_errors_occur_prints_success_messages(self, mock_open, caplog): def func_for_bulk(test): pass @@ -198,22 +199,39 @@ def __call__(self, *args, **kwargs): return ["row1", "row2", "row3"] processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) - processor.run() - capture = capsys.readouterr() - assert "3 processed successfully out of 3." in capture.out - assert errors.get_error_message() not in capture.out - def test_run_when_row_is_endline_does_not_process_row(self, mock_open, capsys): - errors.ERRORED = False + with caplog.at_level(logging.INFO): + processor.run() + assert "3 processed successfully out of 3." in caplog.text + def test_run_when_no_errors_occur_does_not_print_error_message(self, mock_open, caplog): def func_for_bulk(test): pass + class MockRowReader(object): + def __call__(self, *args, **kwargs): + return ["row1", "row2", "row3"] + + processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + + with caplog.at_level(logging.ERROR): + processor.run() + assert get_view_exceptions_location_message() not in caplog.text + + def test_run_when_row_is_endline_does_not_process_row(self, mock_open, caplog): + processed_rows = [] + + def func_for_bulk(test): + processed_rows.append(test) + class MockRowReader(object): def __call__(self, *args, **kwargs): return ["row1", "row2", "\n"] processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) - processor.run() - capture = capsys.readouterr() - assert "2 processed successfully out of 2." in capture.out + with caplog.at_level(logging.INFO): + processor.run() + + assert "row1" in processed_rows + assert "row2" in processed_rows + assert "row3" not in processed_rows diff --git a/tests/test_config.py b/tests/test_config.py index 56bf2eb9a..be171e72c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ import pytest from configparser import ConfigParser +import logging from code42cli import PRODUCT_NAME from code42cli.config import ConfigAccessor, NoConfigProfileError @@ -143,12 +144,12 @@ def test_switch_default_profile_saves(self, config_parser_for_multiple_profiles, assert mock_saver.call_count def test_switch_default_profile_outputs_confirmation( - self, capsys, config_parser_for_multiple_profiles, mock_saver + self, caplog, config_parser_for_multiple_profiles, mock_saver ): accessor = ConfigAccessor(config_parser_for_multiple_profiles) - accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) - capture = capsys.readouterr() - assert "set as the default profile" in capture.out + with caplog.at_level(logging.INFO): + accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) + assert "set as the default profile" in caplog.text def test_create_profile_when_given_default_name_does_not_create(self, config_parser_for_create): accessor = ConfigAccessor(config_parser_for_create) @@ -189,15 +190,15 @@ def test_create_profile_when_not_existing_saves(self, config_parser_for_create, assert mock_saver.call_count def test_create_profile_when_not_existing_outputs_confirmation( - self, capsys, config_parser_for_create, mock_saver + self, caplog, config_parser_for_create, mock_saver ): mock_internal = create_internal_object(False) setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) - capture = capsys.readouterr() - assert "Successfully saved" in capture.out + with caplog.at_level(logging.INFO): + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) + assert "Successfully saved" in caplog.text def test_update_profile_when_no_profile_exists_raises_exception( self, config_parser_for_multiple_profiles diff --git a/tests/test_invoker.py b/tests/test_invoker.py index bd2dc9945..3dddfcebc 100644 --- a/tests/test_invoker.py +++ b/tests/test_invoker.py @@ -1,11 +1,11 @@ import pytest from requests.exceptions import HTTPError -from requests import Response +from requests import Response, Request +import logging from py42.exceptions import Py42ForbiddenError -from code42cli import PRODUCT_NAME from code42cli.commands import Command from code42cli.invoker import CommandInvoker from code42cli.parser import ArgumentParserError, CommandParser @@ -64,30 +64,73 @@ def test_run_nested_cmd_when_raises_argumentparsererror_prints_help(self, mocker invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) assert mock_subparser.print_help.call_count - def test_run_when_errors_occur_from_handler_calls_log_error(self, mocker, mock_parser): - error_logger = mocker.patch("{}.invoker.log_error".format(PRODUCT_NAME)) - ex = Exception() + def test_run_when_errors_occur_from_handler_calls_logs_error(self, mocker, mock_parser, caplog): + ex = Exception("test") cmd = Command("", "top level desc", subcommand_loader=load_subcommands) mock_parser.parse_args.side_effect = ex mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser invoker = CommandInvoker(cmd, mock_parser) - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - error_logger.assert_called_once_with(ex) + with caplog.at_level(logging.ERROR): + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + assert str(ex) in caplog.text + + def test_run_when_errors_occur_from_handler_calls_logs_command( + self, mocker, mock_parser, caplog + ): + ex = Exception("test") + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + mock_parser.parse_args.side_effect = ex + mock_subparser = mocker.MagicMock() + mock_parser.prepare_command.return_value = mock_subparser + invoker = CommandInvoker(cmd, mock_parser) + with caplog.at_level(logging.ERROR): + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + assert "code42 testsub1 inner1 one two --invalid test" in caplog.text - def test_run_when_forbidden_error_occurs_prints_message(self, mocker, mock_parser, capsys): - mocker.patch("{}.invoker.log_error".format(PRODUCT_NAME)) + def test_run_when_forbidden_error_occurs_logs_message(self, mocker, mock_parser, caplog): http_error = mocker.MagicMock(spec=HTTPError) http_error.response = mocker.MagicMock(spec=Response) + http_error.response.request = None cmd = Command("", "top level desc", subcommand_loader=load_subcommands) mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser invoker = CommandInvoker(cmd, mock_parser) - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - capture = capsys.readouterr() - assert ( - u"You do not have the necessary permissions to perform this task. Try using or " - u"creating a different profile." in capture.out - ) + with caplog.at_level(logging.ERROR): + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + assert ( + u"You do not have the necessary permissions to perform this task. Try using or " + u"creating a different profile." in caplog.text + ) + + def test_run_when_forbidden_error_occurs_logs_command(self, mocker, mock_parser, caplog): + http_error = mocker.MagicMock(spec=HTTPError) + http_error.response = mocker.MagicMock(spec=Response) + http_error.response.request = None + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) + mock_subparser = mocker.MagicMock() + mock_parser.prepare_command.return_value = mock_subparser + invoker = CommandInvoker(cmd, mock_parser) + + with caplog.at_level(logging.ERROR): + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + assert "code42 testsub1 inner1 one two --invalid test" in caplog.text + + def test_run_when_forbidden_error_occurs_logs_request(self, mocker, mock_parser, caplog): + http_error = mocker.MagicMock(spec=HTTPError) + http_error.response = mocker.MagicMock(spec=Response) + request = mocker.MagicMock(spec=Request) + request.body = {"foo": "bar"} + http_error.response.request = request + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) + mock_subparser = mocker.MagicMock() + mock_parser.prepare_command.return_value = mock_subparser + invoker = CommandInvoker(cmd, mock_parser) + + with caplog.at_level(logging.ERROR): + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + assert str(request.body) in caplog.text diff --git a/tests/test_logger.py b/tests/test_logger.py index ae82da971..9e8554f4e 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,14 +1,83 @@ -from logging.handlers import RotatingFileHandler +import logging +from requests import Request -import code42cli.logger as factory +from code42cli.logger import ( + add_handler_to_logger, + logger_has_handlers, + get_view_exceptions_location_message, + CliLogger, +) +from code42cli.util import get_user_project_path -def test_get_error_logger_when_called_twice_only_sets_handler_once(): - _ = factory.get_error_logger() - logger = factory.get_error_logger() - assert len(logger.handlers) == 1 +def test_add_handler_to_logger_does_as_expected(): + logger = logging.getLogger("TEST_CODE42_CLI") + formatter = logging.Formatter() + handler = logging.Handler() + add_handler_to_logger(logger, handler, formatter) + assert handler in logger.handlers + assert handler.formatter == formatter -def test_get_error_logger_uses_rotating_file_handler(): - logger = factory.get_error_logger() - assert type(logger.handlers[0]) == RotatingFileHandler +def test_logger_has_handlers_when_logger_has_handlers_returns_true(): + logger = logging.getLogger("TEST_CODE42_CLI") + handler = logging.Handler() + logger.addHandler(handler) + assert logger_has_handlers(logger) + + +def test_logger_has_handlers_when_logger_does_not_have_handlers_returns_false(): + logger = logging.getLogger("TEST_CODE42_CLI") + logger.handlers = [] + assert not logger_has_handlers(logger) + + +def test_get_view_exceptions_location_message_returns_expected_message(): + actual = get_view_exceptions_location_message() + path = get_user_project_path() + expected = u"View exceptions that occurred at {}log/code42_errors.log.".format(path) + assert actual == expected + + +class TestCliLogger(object): + + _logger = CliLogger() + + def test_print_info_logs_expected_text_at_expected_level(self, caplog): + with caplog.at_level(logging.INFO): + self._logger.print_info("TEST") + assert "TEST" in caplog.text + + def test_print_bold_logs_expected_text_at_expected_level(self, caplog): + with caplog.at_level(logging.INFO): + self._logger.print_bold("TEST") + assert "TEST" in caplog.text + + def test_print_and_log_error_logs_expected_text_at_expected_level(self, caplog): + with caplog.at_level(logging.ERROR): + self._logger.print_and_log_error("TEST") + assert "TEST" in caplog.text + + def test_print_and_log_info_logs_expected_text_at_expected_level(self, caplog): + with caplog.at_level(logging.ERROR): + self._logger.print_and_log_info("TEST") + assert "TEST" in caplog.text + + def test_log_error_logs_expected_text_at_expected_level(self, caplog): + with caplog.at_level(logging.ERROR): + ex = Exception("TEST") + self._logger.log_error(ex) + assert str(ex) in caplog.text + + def test_print_errors_occurred_message_logs_expected_text_at_expected_level(self, caplog): + with caplog.at_level(logging.ERROR): + self._logger.print_errors_occurred_message() + assert "View exceptions that occurred at" in caplog.text + + def test_log_verbose_error_logs_expected_text_at_expected_level(self, mocker, caplog): + with caplog.at_level(logging.ERROR): + request = mocker.MagicMock(sepc=Request) + request.body = {"foo": "bar"} + self._logger.log_verbose_error("code42 dothing --flag YES", request) + assert "'code42 dothing --flag YES'" in caplog.text + assert "Request parameters: {'foo': 'bar'}" in caplog.text diff --git a/tests/test_parser.py b/tests/test_parser.py index 069da5a09..6f32b8fcc 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -53,7 +53,7 @@ def test_prepare_command_when_required_args_help_outputs_help(self, capsys): parser.prepare_command(cmd, parts) success = False try: - parsed_args = parser.parse_args(["runnable", "-h"]) + parser.parse_args(["runnable", "-h"]) except SystemExit: success = True captured = capsys.readouterr() diff --git a/tests/test_profile.py b/tests/test_profile.py index d4dff1fe9..2862dfa84 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,4 +1,5 @@ import pytest +import logging import code42cli.profile as cliprofile from code42cli import PRODUCT_NAME @@ -69,7 +70,7 @@ def test_get_profile_returns_expected_profile(config_accessor): def test_get_profile_when_config_accessor_throws_exits(config_accessor): config_accessor.get_profile.side_effect = NoConfigProfileError() with pytest.raises(SystemExit): - profile = cliprofile.get_profile("testprofilename") + cliprofile.get_profile("testprofilename") def test_default_profile_exists_when_exists_returns_true(config_accessor): @@ -118,6 +119,8 @@ def test_profile_exists_when_not_exists_returns_false(config_accessor): def test_switch_default_profile_switches_to_expected_profile(config_accessor): + mock_section = MockSection("switchtome") + config_accessor.get_profile.return_value = mock_section cliprofile.switch_default_profile("switchtome") config_accessor.switch_default_profile.assert_called_once_with("switchtome") @@ -134,16 +137,16 @@ def test_create_profile_uses_expected_profile_values(config_accessor): ) -def test_create_profile_if_profile_exists_exits(mocker, capsys, config_accessor): +def test_create_profile_if_profile_exists_exits(mocker, caplog, config_accessor): config_accessor.get_profile.return_value = mocker.MagicMock() success = True - try: - cliprofile.create_profile("foo", "bar", "baz", True) - except SystemExit: - success = True - capture = capsys.readouterr() - assert "already exists" in capture.out - assert success + with caplog.at_level(logging.ERROR): + try: + cliprofile.create_profile("foo", "bar", "baz", True) + except SystemExit: + success = True + assert "already exists" in caplog.text + assert success def test_get_all_profiles_returns_expected_profile_list(config_accessor): From 99bf3ca2d5a6b53f344545575b416f72f6718eed Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 11 May 2020 16:07:37 -0500 Subject: [PATCH 044/349] Fix/rm escs in log (#54) --- src/code42cli/logger.py | 31 +++++++++++++++------- tests/cmds/detectionlists/test_init.py | 2 +- tests/test_logger.py | 36 +++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 3982bb628..2c6cdaa13 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -1,6 +1,7 @@ import logging, sys, traceback from logging.handlers import RotatingFileHandler from threading import Lock +import copy from code42cli.compat import str from code42cli.util import get_user_project_path, is_interactive @@ -79,6 +80,20 @@ def _get_user_error_logger(): return _get_error_file_logger() +class RedStderrHandler(logging.StreamHandler): + """Logging handler for logging error messages to stderr using red scary text prefixed by the + word `ERROR`. For logging info to stderr, it will not add the scary red text.""" + def __init__(self): + super(RedStderrHandler, self).__init__(sys.stderr) + + def emit(self, record): + if record.levelno == logging.ERROR: + message = _get_red_error_text(record.msg) + record = copy.copy(record) + record.msg = message + super(RedStderrHandler, self).emit(record) + + def _get_interactive_user_error_logger(): """This logger has two handlers, one for stderr and one for the error log file.""" logger = logging.getLogger(u"code42_stderr_main") @@ -87,7 +102,7 @@ def _get_interactive_user_error_logger(): with logger_deps_lock: if not logger_has_handlers(logger): - stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler = RedStderrHandler() stderr_formatter = _get_standard_formatter() stderr_handler.setFormatter(stderr_formatter) @@ -98,7 +113,7 @@ def _get_interactive_user_error_logger(): add_handler_to_logger(logger, stderr_handler, stderr_formatter) add_handler_to_logger(logger, file_handler, file_formatter) - logger.setLevel(logging.ERROR) + logger.setLevel(logging.INFO) return logger return logger @@ -139,14 +154,12 @@ def print_bold(self, message): self._info_logger.info(u"\033[1m{}\033[0m".format(message)) def print_and_log_error(self, message): - """For not interrupting stdout output. Excludes red text and 'ERROR: ' from `error()`. - """ - """Logs red text to stderr and a log file.""" - self._user_error_logger.error(_get_red_error_text(message)) + """Logs red error text to stderr and non-color messages to the log file.""" + self._user_error_logger.error(message) def print_and_log_info(self, message): - """Logs red text to stderr and a log file.""" - self._user_error_logger.error(message) + """Prints to stderr and the log file.""" + self._user_error_logger.info(message) def log_error(self, err): if err: @@ -171,7 +184,7 @@ def log_verbose_error(self, invocation_str=None, http_request=None): prefix = ( u"Exception occurred." if not invocation_str - else "Exception occurred from input: '{}'.".format(invocation_str) + else u"Exception occurred from input: '{}'.".format(invocation_str) ) message = u"{}. See error below.".format(prefix) self.log_error(message) diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 07c8b0106..832407cfa 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -36,7 +36,7 @@ def test_get_user_id_when_user_does_not_exist_logs_error(sdk_without_user, caplo try: get_user_id(sdk_without_user, "risky employee") except UserDoesNotExistError: - assert "ERROR: User 'risky employee' does not exist." in caplog.text + assert "User 'risky employee' does not exist." in caplog.text def test_update_user_adds_cloud_alias(sdk_with_user, profile): diff --git a/tests/test_logger.py b/tests/test_logger.py index 9e8554f4e..4768a1b70 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,10 +1,12 @@ import logging +from logging.handlers import RotatingFileHandler from requests import Request from code42cli.logger import ( add_handler_to_logger, logger_has_handlers, get_view_exceptions_location_message, + RedStderrHandler, CliLogger, ) from code42cli.util import get_user_project_path @@ -39,10 +41,42 @@ def test_get_view_exceptions_location_message_returns_expected_message(): assert actual == expected +class TestRedStderrHandler(object): + def test_emit_when_error_adds_red_text(self, mocker, caplog): + handler = RedStderrHandler() + record = mocker.MagicMock(spec=logging.LogRecord) + record.msg = "TEST" + record.levelno = logging.ERROR + + logger = mocker.patch("logging.StreamHandler.emit") + handler.emit(record) + actual = logger.call_args[0][0].msg + assert actual == "\x1b[91mERROR: TEST\x1b[0m" + + def test_emit_when_info_does_not_alter(self, mocker, caplog): + handler = RedStderrHandler() + record = mocker.MagicMock(spec=logging.LogRecord) + record.msg = "TEST" + record.levelno = logging.INFO + + logger = mocker.patch("logging.StreamHandler.emit") + handler.emit(record) + actual = logger.call_args[0][0].msg + assert actual == "TEST" + + class TestCliLogger(object): _logger = CliLogger() + def test_init_creates_user_error_logger_with_expected_handlers(self, mocker): + is_interactive = mocker.patch("code42cli.logger.is_interactive") + is_interactive.return_value = True + logger = CliLogger() + handler_types = [type(h) for h in logger._user_error_logger.handlers] + assert RedStderrHandler in handler_types + assert RotatingFileHandler in handler_types + def test_print_info_logs_expected_text_at_expected_level(self, caplog): with caplog.at_level(logging.INFO): self._logger.print_info("TEST") @@ -59,7 +93,7 @@ def test_print_and_log_error_logs_expected_text_at_expected_level(self, caplog): assert "TEST" in caplog.text def test_print_and_log_info_logs_expected_text_at_expected_level(self, caplog): - with caplog.at_level(logging.ERROR): + with caplog.at_level(logging.INFO): self._logger.print_and_log_info("TEST") assert "TEST" in caplog.text From d88d764f4de3ef0375da2e60a9f01f974ae1ac99 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 11 May 2020 16:10:13 -0500 Subject: [PATCH 045/349] Chore/user already added error (#55) --- src/code42cli/cmds/detectionlists/__init__.py | 19 +++++++++++++++ .../cmds/detectionlists/departing_employee.py | 13 ++++++++-- .../cmds/detectionlists/high_risk_employee.py | 13 ++++++++-- tests/cmds/detectionlists/conftest.py | 23 ++++++++++++++++++ .../detectionlists/test_departing_employee.py | 24 +++++++++++++++++++ .../detectionlists/test_high_risk_employee.py | 23 ++++++++++++++++++ 6 files changed, 111 insertions(+), 4 deletions(-) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 0d55f953d..725d20bc2 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -9,6 +9,25 @@ ) +class UserAlreadyAddedError(Exception): + def __init__(self, username, list_name): + msg = u"'{}' is already on the {} list.".format(username, list_name) + super(UserAlreadyAddedError, self).__init__(msg) + + +def handle_bad_request_during_add(bad_request_err, username_tried_adding, list_name): + if _error_is_user_already_added(bad_request_err.response.text): + logger = get_main_cli_logger() + new_err = UserAlreadyAddedError(username_tried_adding, list_name) + logger.print_and_log_error(new_err) + return True + return False + + +def _error_is_user_already_added(bad_request_error_text): + return u"User already on list" in bad_request_error_text + + class UserDoesNotExistError(Exception): """An error to represent a username that is not in our system. The CLI shows this error when the user tries to add or remove a user that does not exist. This error is not shown during diff --git a/src/code42cli/cmds/detectionlists/departing_employee.py b/src/code42cli/cmds/detectionlists/departing_employee.py index c5c6dbf8a..da0a9f1c9 100644 --- a/src/code42cli/cmds/detectionlists/departing_employee.py +++ b/src/code42cli/cmds/detectionlists/departing_employee.py @@ -4,7 +4,11 @@ load_user_descriptions, get_user_id, update_user, + handle_bad_request_during_add, ) +from code42cli.cmds.detectionlists.enums import DetectionLists + +from py42.exceptions import Py42BadRequestError def load_subcommands(): @@ -33,8 +37,13 @@ def add_departing_employee( notes: (str): Notes about the employee. """ user_id = get_user_id(sdk, username) - sdk.detectionlists.departing_employee.add(user_id, departure_date) - update_user(sdk, user_id, cloud_alias, notes=notes) + + try: + sdk.detectionlists.departing_employee.add(user_id, departure_date) + update_user(sdk, user_id, cloud_alias, notes=notes) + except Py42BadRequestError as err: + if not handle_bad_request_during_add(err, username, DetectionLists.DEPARTING_EMPLOYEE): + raise def remove_departing_employee(sdk, profile, username): diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index c5fe1211b..b36b0d2c9 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -5,10 +5,14 @@ load_username_description, get_user_id, update_user, + handle_bad_request_during_add, ) +from code42cli.cmds.detectionlists.enums import DetectionLists from code42cli.cmds.detectionlists.enums import DetectionListUserKeys from code42cli.commands import Command +from py42.exceptions import Py42BadRequestError + def load_subcommands(): @@ -65,8 +69,13 @@ def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=No """ risk_tag = _handle_list_args(risk_tag) user_id = get_user_id(sdk, username) - sdk.detectionlists.high_risk_employee.add(user_id) - update_user(sdk, user_id, cloud_alias, risk_tag, notes) + + try: + sdk.detectionlists.high_risk_employee.add(user_id) + update_user(sdk, user_id, cloud_alias, risk_tag, notes) + except Py42BadRequestError as err: + if not handle_bad_request_during_add(err, username, DetectionLists.HIGH_RISK_EMPLOYEE): + raise def remove_high_risk_employee(sdk, profile, username): diff --git a/tests/cmds/detectionlists/conftest.py b/tests/cmds/detectionlists/conftest.py index cad9897d3..6904e86e4 100644 --- a/tests/cmds/detectionlists/conftest.py +++ b/tests/cmds/detectionlists/conftest.py @@ -1,4 +1,7 @@ import pytest +from requests import Response, HTTPError + +from py42.exceptions import Py42BadRequestError TEST_ID = "TEST_ID" @@ -14,3 +17,23 @@ def sdk_with_user(sdk): def sdk_without_user(sdk): sdk.users.get_by_username.return_value = {"users": []} return sdk + + +@pytest.fixture +def bad_request_for_user_already_added(mocker): + resp = mocker.MagicMock(spec=Response) + resp.text = "User already on list" + return _create_bad_request_mock(resp) + + +@pytest.fixture +def bad_request_for_other_reasons(mocker): + resp = mocker.MagicMock(spec=Response) + resp.text = "Some other, non-supported reason for a bad request" + return _create_bad_request_mock(resp) + + +def _create_bad_request_mock(resp): + base_err = HTTPError() + base_err.response = resp + return Py42BadRequestError(base_err) diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py index 80711df10..757411174 100644 --- a/tests/cmds/detectionlists/test_departing_employee.py +++ b/tests/cmds/detectionlists/test_departing_employee.py @@ -6,8 +6,11 @@ add_departing_employee, remove_departing_employee, ) + from .conftest import TEST_ID +from py42.exceptions import Py42BadRequestError + _EMPLOYEE = "departing employee" @@ -46,6 +49,27 @@ def test_add_departing_employee_when_user_does_not_exist_prints_error( assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text +def test_add_departing_employee_when_user_already_added_prints_error( + sdk_with_user, profile, bad_request_for_user_already_added, caplog +): + sdk_with_user.detectionlists.departing_employee.add.side_effect = ( + bad_request_for_user_already_added + ) + add_departing_employee(sdk_with_user, profile, _EMPLOYEE) + with caplog.at_level(logging.ERROR): + assert _EMPLOYEE in caplog.text + assert "already on the" in caplog.text + assert "departing-employee" in caplog.text + + +def test_add_departing_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( + sdk_with_user, profile, bad_request_for_other_reasons, caplog +): + sdk_with_user.detectionlists.departing_employee.add.side_effect = bad_request_for_other_reasons + with pytest.raises(Py42BadRequestError): + add_departing_employee(sdk_with_user, profile, _EMPLOYEE) + + def test_remove_departing_employee_calls_remove(sdk_with_user, profile): remove_departing_employee(sdk_with_user, profile, _EMPLOYEE) sdk_with_user.detectionlists.departing_employee.remove.assert_called_once_with(TEST_ID) diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index 2acfc87cd..5ead0d865 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -10,6 +10,8 @@ ) from .conftest import TEST_ID +from py42.exceptions import Py42BadRequestError + _EMPLOYEE = "risky employee" @@ -59,6 +61,27 @@ def test_add_high_risk_employee_when_user_does_not_exist_prints_error( assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text +def test_add_high_risk_employee_when_user_already_added_prints_error( + sdk_with_user, profile, bad_request_for_user_already_added, caplog +): + sdk_with_user.detectionlists.high_risk_employee.add.side_effect = ( + bad_request_for_user_already_added + ) + add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) + with caplog.at_level(logging.ERROR): + assert _EMPLOYEE in caplog.text + assert "already on the" in caplog.text + assert "high-risk-employee" in caplog.text + + +def test_add_high_risk_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( + sdk_with_user, profile, bad_request_for_other_reasons, caplog +): + sdk_with_user.detectionlists.high_risk_employee.add.side_effect = bad_request_for_other_reasons + with pytest.raises(Py42BadRequestError): + add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) + + def test_remove_high_risk_employee_calls_remove(sdk_with_user, profile): remove_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) sdk_with_user.detectionlists.high_risk_employee.remove.assert_called_once_with(TEST_ID) From 3b23b712a253dc0cd5e76b1080ab6928475d1c78 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 11 May 2020 17:06:42 -0500 Subject: [PATCH 046/349] Fix bulk (#56) --- src/code42cli/bulk.py | 5 ++++- src/code42cli/cmds/detectionlists/__init__.py | 5 +---- src/code42cli/logger.py | 1 + .../detectionlists/test_departing_employee.py | 13 +++++------ .../detectionlists/test_high_risk_employee.py | 13 +++++------ tests/test_bulk.py | 22 ++++++++++++++++--- 6 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 7f7abf0d8..042b0292d 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -94,7 +94,10 @@ def _process_csv_row(self, row): # Removes problems from including extra comments. Error messages from out of order args # are more indicative this way too. row.pop(None, None) - self.__worker.do_async(lambda *args, **kwargs: self._row_handler(*args, **kwargs), **row) + row_values = {key: val if val != u"" else None for key, val in row.items()} + self.__worker.do_async( + lambda *args, **kwargs: self._row_handler(*args, **kwargs), **row_values + ) def _process_flat_file_row(self, row): if row: diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 725d20bc2..78c1b0d7b 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -17,10 +17,7 @@ def __init__(self, username, list_name): def handle_bad_request_during_add(bad_request_err, username_tried_adding, list_name): if _error_is_user_already_added(bad_request_err.response.text): - logger = get_main_cli_logger() - new_err = UserAlreadyAddedError(username_tried_adding, list_name) - logger.print_and_log_error(new_err) - return True + raise UserAlreadyAddedError(username_tried_adding, list_name) return False diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 2c6cdaa13..3b6c0c953 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -83,6 +83,7 @@ def _get_user_error_logger(): class RedStderrHandler(logging.StreamHandler): """Logging handler for logging error messages to stderr using red scary text prefixed by the word `ERROR`. For logging info to stderr, it will not add the scary red text.""" + def __init__(self): super(RedStderrHandler, self).__init__(sys.stderr) diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py index 757411174..b0481ec39 100644 --- a/tests/cmds/detectionlists/test_departing_employee.py +++ b/tests/cmds/detectionlists/test_departing_employee.py @@ -1,7 +1,7 @@ import pytest import logging -from code42cli.cmds.detectionlists import UserDoesNotExistError +from code42cli.cmds.detectionlists import UserDoesNotExistError, UserAlreadyAddedError from code42cli.cmds.detectionlists.departing_employee import ( add_departing_employee, remove_departing_employee, @@ -49,17 +49,14 @@ def test_add_departing_employee_when_user_does_not_exist_prints_error( assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text -def test_add_departing_employee_when_user_already_added_prints_error( - sdk_with_user, profile, bad_request_for_user_already_added, caplog +def test_add_departing_employee_when_user_already_added_raises_UserAlreadyAddedError( + sdk_with_user, profile, bad_request_for_user_already_added ): sdk_with_user.detectionlists.departing_employee.add.side_effect = ( bad_request_for_user_already_added ) - add_departing_employee(sdk_with_user, profile, _EMPLOYEE) - with caplog.at_level(logging.ERROR): - assert _EMPLOYEE in caplog.text - assert "already on the" in caplog.text - assert "departing-employee" in caplog.text + with pytest.raises(UserAlreadyAddedError): + add_departing_employee(sdk_with_user, profile, _EMPLOYEE) def test_add_departing_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index 5ead0d865..056a9bbbe 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -1,7 +1,7 @@ import pytest import logging -from code42cli.cmds.detectionlists import UserDoesNotExistError +from code42cli.cmds.detectionlists import UserDoesNotExistError, UserAlreadyAddedError from code42cli.cmds.detectionlists.high_risk_employee import ( add_high_risk_employee, remove_high_risk_employee, @@ -61,17 +61,14 @@ def test_add_high_risk_employee_when_user_does_not_exist_prints_error( assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text -def test_add_high_risk_employee_when_user_already_added_prints_error( - sdk_with_user, profile, bad_request_for_user_already_added, caplog +def test_add_high_risk_employee_when_user_already_added_raises_UserAlreadyAddedError( + sdk_with_user, profile, bad_request_for_user_already_added ): sdk_with_user.detectionlists.high_risk_employee.add.side_effect = ( bad_request_for_user_already_added ) - add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) - with caplog.at_level(logging.ERROR): - assert _EMPLOYEE in caplog.text - assert "already on the" in caplog.text - assert "high-risk-employee" in caplog.text + with pytest.raises(UserAlreadyAddedError): + add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) def test_add_high_risk_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 0f583f365..edfc70f17 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -218,7 +218,7 @@ def __call__(self, *args, **kwargs): processor.run() assert get_view_exceptions_location_message() not in caplog.text - def test_run_when_row_is_endline_does_not_process_row(self, mock_open, caplog): + def test_run_when_row_is_endline_does_not_process_row(self, mock_open): processed_rows = [] def func_for_bulk(test): @@ -229,9 +229,25 @@ def __call__(self, *args, **kwargs): return ["row1", "row2", "\n"] processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) - with caplog.at_level(logging.INFO): - processor.run() + processor.run() assert "row1" in processed_rows assert "row2" in processed_rows assert "row3" not in processed_rows + + def test_run_when_reader_returns_dict_rows_containing_empty_strs_converts_them_to_none( + self, mock_open + ): + processed_rows = [] + + def func_for_bulk(test1, test2): + processed_rows.append((test1, test2)) + + class MockDictReader(object): + def __call__(self, *args, **kwargs): + return [{"test1": "", "test2": "foo"}, {"test1": "bar", "test2": u""}] + + processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) + processor.run() + assert (None, "foo") in processed_rows + assert ("bar", None) in processed_rows From d7b8c586884bc111f60c4bd30d3f079a1c426867 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 13 May 2020 14:10:44 -0500 Subject: [PATCH 047/349] INTEG 1001 - Risk Tag Error handling (#57) --- CHANGELOG.md | 6 + src/code42cli/cmds/detectionlists/__init__.py | 39 ++++- .../cmds/detectionlists/departing_employee.py | 7 +- src/code42cli/cmds/detectionlists/enums.py | 23 +++ .../cmds/detectionlists/high_risk_employee.py | 28 ++-- tests/cmds/detectionlists/conftest.py | 4 +- .../detectionlists/test_departing_employee.py | 4 +- .../detectionlists/test_high_risk_employee.py | 134 ++++++++++++++++-- tests/cmds/detectionlists/test_init.py | 44 +++++- 9 files changed, 247 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5e870a6..29e8dac0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Additional information in the error log file: - The full command path for the command that errored. - User-facing error messages you see during adhoc sessions. +- A custom error in the error log when you try adding unknown risk tags to user. +- A custom error in the error log when you try adding a user to a detection list who is already added. + +### Fixed + +- Fixed bug in bulk commands where value-less fields in csv files were treated as empty strings instead of None. ### 0.5.3 - 2020-05-04 diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 78c1b0d7b..408bef8bb 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,3 +1,5 @@ +from py42.exceptions import Py42BadRequestError + from code42cli.compat import str from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory from code42cli.bulk import generate_template, run_bulk_process, CSVReader, FlatFileReader @@ -6,6 +8,7 @@ BulkCommandType, DetectionLists, DetectionListUserKeys, + RiskTags, ) @@ -15,7 +18,15 @@ def __init__(self, username, list_name): super(UserAlreadyAddedError, self).__init__(msg) -def handle_bad_request_during_add(bad_request_err, username_tried_adding, list_name): +class UnknownRiskTagError(Exception): + def __init__(self, bad_tags): + tags = u", ".join(bad_tags) + super(UnknownRiskTagError, self).__init__( + u"The following risk tags are unknown: '{}'.".format(tags) + ) + + +def try_handle_user_already_added_error(bad_request_err, username_tried_adding, list_name): if _error_is_user_already_added(bad_request_err.response.text): raise UserAlreadyAddedError(username_tried_adding, list_name) return False @@ -209,6 +220,7 @@ def update_user(sdk, user_id, cloud_alias=None, risk_tag=None, notes=None): """Updates a detection list user. Args: + sdk (py42.sdk.SDKClient): py42 sdk. user_id (str or unicode): The ID of the user to update. This is their `userUid` found from `sdk.users.get_by_username()`. cloud_alias (str or unicode): A cloud alias to add to the user. @@ -218,6 +230,29 @@ def update_user(sdk, user_id, cloud_alias=None, risk_tag=None, notes=None): if cloud_alias: sdk.detectionlists.add_user_cloud_alias(user_id, cloud_alias) if risk_tag: - sdk.detectionlists.add_user_risk_tags(user_id, risk_tag) + try_add_risk_tags(sdk, user_id, risk_tag) if notes: sdk.detectionlists.update_user_notes(user_id, notes) + + +def try_add_risk_tags(sdk, user_id, risk_tag): + _try_add_or_remove_risk_tags(user_id, risk_tag, sdk.detectionlists.add_user_risk_tags) + + +def try_remove_risk_tags(sdk, user_id, risk_tag): + _try_add_or_remove_risk_tags(user_id, risk_tag, sdk.detectionlists.remove_user_risk_tags) + + +def _try_add_or_remove_risk_tags(user_id, risk_tag, func): + try: + func(user_id, risk_tag) + except Py42BadRequestError: + _try_handle_bad_risk_tag(risk_tag) + raise + + +def _try_handle_bad_risk_tag(tags): + options = list(RiskTags()) + unknowns = [tag for tag in tags if tag not in options] if tags else None + if unknowns: + raise UnknownRiskTagError(unknowns) diff --git a/src/code42cli/cmds/detectionlists/departing_employee.py b/src/code42cli/cmds/detectionlists/departing_employee.py index da0a9f1c9..5224105f4 100644 --- a/src/code42cli/cmds/detectionlists/departing_employee.py +++ b/src/code42cli/cmds/detectionlists/departing_employee.py @@ -4,7 +4,7 @@ load_user_descriptions, get_user_id, update_user, - handle_bad_request_during_add, + try_handle_user_already_added_error, ) from code42cli.cmds.detectionlists.enums import DetectionLists @@ -42,8 +42,9 @@ def add_departing_employee( sdk.detectionlists.departing_employee.add(user_id, departure_date) update_user(sdk, user_id, cloud_alias, notes=notes) except Py42BadRequestError as err: - if not handle_bad_request_during_add(err, username, DetectionLists.DEPARTING_EMPLOYEE): - raise + list_name = DetectionLists.DEPARTING_EMPLOYEE + try_handle_user_already_added_error(err, username, list_name) + raise def remove_departing_employee(sdk, profile, username): diff --git a/src/code42cli/cmds/detectionlists/enums.py b/src/code42cli/cmds/detectionlists/enums.py index aa0697cdf..f36dbf6ab 100644 --- a/src/code42cli/cmds/detectionlists/enums.py +++ b/src/code42cli/cmds/detectionlists/enums.py @@ -16,3 +16,26 @@ class DetectionListUserKeys(object): USERNAME = u"username" NOTES = u"notes" RISK_TAG = u"risk_tag" + + +class RiskTags(object): + FLIGHT_RISK = u"FLIGHT_RISK" + HIGH_IMPACT_EMPLOYEE = u"HIGH_IMPACT_EMPLOYEE" + ELEVATED_ACCESS_PRIVILEGES = u"ELEVATED_ACCESS_PRIVILEGES" + PERFORMANCE_CONCERNS = u"PERFORMANCE_CONCERNS" + SUSPICIOUS_SYSTEM_ACTIVITY = u"SUSPICIOUS_SYSTEM_ACTIVITY" + POOR_SECURITY_PRACTICES = u"POOR_SECURITY_PRACTICES" + CONTRACT_EMPLOYEE = u"CONTRACT_EMPLOYEE" + + def __iter__(self): + return iter( + [ + self.FLIGHT_RISK, + self.HIGH_IMPACT_EMPLOYEE, + self.ELEVATED_ACCESS_PRIVILEGES, + self.PERFORMANCE_CONCERNS, + self.SUSPICIOUS_SYSTEM_ACTIVITY, + self.POOR_SECURITY_PRACTICES, + self.CONTRACT_EMPLOYEE, + ] + ) diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index b36b0d2c9..43edacc53 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -5,17 +5,17 @@ load_username_description, get_user_id, update_user, - handle_bad_request_during_add, + try_handle_user_already_added_error, + try_add_risk_tags, + try_remove_risk_tags, ) -from code42cli.cmds.detectionlists.enums import DetectionLists -from code42cli.cmds.detectionlists.enums import DetectionListUserKeys +from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags from code42cli.commands import Command from py42.exceptions import Py42BadRequestError def load_subcommands(): - handlers = _create_handlers() detection_list = DetectionList.create_high_risk_employee_list(handlers) cmd_list = detection_list.load_subcommands() @@ -47,13 +47,13 @@ def _create_handlers(): def add_risk_tags(sdk, profile, username, risk_tag): risk_tag = _handle_list_args(risk_tag) user_id = get_user_id(sdk, username) - sdk.detectionlists.add_user_risk_tags(user_id, risk_tag) + try_add_risk_tags(sdk, user_id, risk_tag) def remove_risk_tags(sdk, profile, username, risk_tag): risk_tag = _handle_list_args(risk_tag) user_id = get_user_id(sdk, username) - sdk.detectionlists.remove_user_risk_tags(user_id, risk_tag) + try_remove_risk_tags(sdk, user_id, risk_tag) def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=None, notes=None): @@ -74,8 +74,9 @@ def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=No sdk.detectionlists.high_risk_employee.add(user_id) update_user(sdk, user_id, cloud_alias, risk_tag, notes) except Py42BadRequestError as err: - if not handle_bad_request_during_add(err, username, DetectionLists.HIGH_RISK_EMPLOYEE): - raise + list_name = DetectionLists.HIGH_RISK_EMPLOYEE + try_handle_user_already_added_error(err, username, list_name) + raise def remove_high_risk_employee(sdk, profile, username): @@ -93,16 +94,9 @@ def remove_high_risk_employee(sdk, profile, username): def _load_risk_tag_description(argument_collection): risk_tag = argument_collection.arg_configs[DetectionListUserKeys.RISK_TAG] risk_tag.as_multi_val_param() + tags = u", ".join(list(RiskTags())) risk_tag.set_help( - u"Risk tags associated with the employee. " - u"Options include " - u"[HIGH_IMPACT_EMPLOYEE, " - u"ELEVATED_ACCESS_PRIVILEGES, " - u"PERFORMANCE_CONCERNS, " - u"FLIGHT_RISK, " - u"SUSPICIOUS_SYSTEM_ACTIVITY, " - u"POOR_SECURITY_PRACTICES, " - u"CONTRACT_EMPLOYEE]" + u"Risk tags associated with the employee. Options include: [{}].".format(tags) ) diff --git a/tests/cmds/detectionlists/conftest.py b/tests/cmds/detectionlists/conftest.py index 6904e86e4..177aa91f1 100644 --- a/tests/cmds/detectionlists/conftest.py +++ b/tests/cmds/detectionlists/conftest.py @@ -27,9 +27,9 @@ def bad_request_for_user_already_added(mocker): @pytest.fixture -def bad_request_for_other_reasons(mocker): +def generic_bad_request(mocker): resp = mocker.MagicMock(spec=Response) - resp.text = "Some other, non-supported reason for a bad request" + resp.text = "TEST" return _create_bad_request_mock(resp) diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py index b0481ec39..957c98775 100644 --- a/tests/cmds/detectionlists/test_departing_employee.py +++ b/tests/cmds/detectionlists/test_departing_employee.py @@ -60,9 +60,9 @@ def test_add_departing_employee_when_user_already_added_raises_UserAlreadyAddedE def test_add_departing_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( - sdk_with_user, profile, bad_request_for_other_reasons, caplog + sdk_with_user, profile, generic_bad_request, caplog ): - sdk_with_user.detectionlists.departing_employee.add.side_effect = bad_request_for_other_reasons + sdk_with_user.detectionlists.departing_employee.add.side_effect = generic_bad_request with pytest.raises(Py42BadRequestError): add_departing_employee(sdk_with_user, profile, _EMPLOYEE) diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index 056a9bbbe..a7126d8ec 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -1,13 +1,18 @@ import pytest import logging -from code42cli.cmds.detectionlists import UserDoesNotExistError, UserAlreadyAddedError +from code42cli.cmds.detectionlists import ( + UserDoesNotExistError, + UserAlreadyAddedError, + UnknownRiskTagError, +) from code42cli.cmds.detectionlists.high_risk_employee import ( add_high_risk_employee, remove_high_risk_employee, add_risk_tags, remove_risk_tags, ) +from code42cli.cmds.detectionlists.enums import RiskTags from .conftest import TEST_ID from py42.exceptions import Py42BadRequestError @@ -72,13 +77,42 @@ def test_add_high_risk_employee_when_user_already_added_raises_UserAlreadyAddedE def test_add_high_risk_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( - sdk_with_user, profile, bad_request_for_other_reasons, caplog + sdk_with_user, profile, generic_bad_request, caplog ): - sdk_with_user.detectionlists.high_risk_employee.add.side_effect = bad_request_for_other_reasons + sdk_with_user.detectionlists.high_risk_employee.add.side_effect = generic_bad_request with pytest.raises(Py42BadRequestError): add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) +def test_add_high_risk_employee_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( + sdk_with_user, profile, generic_bad_request +): + sdk_with_user.detectionlists.add_user_risk_tags.side_effect = generic_bad_request + foo = "foo" + bar = "bar" + mysterious_coffee_breaks = "MYSTERIOUS_COFFEE_BREAKS" + try: + add_high_risk_employee( + sdk_with_user, + profile, + _EMPLOYEE, + risk_tag="{} {} {} {} {} {} {}".format( + RiskTags.ELEVATED_ACCESS_PRIVILEGES, + foo, + RiskTags.HIGH_IMPACT_EMPLOYEE, + bar, + mysterious_coffee_breaks, + RiskTags.SUSPICIOUS_SYSTEM_ACTIVITY, + RiskTags.CONTRACT_EMPLOYEE, + ), + ) + except UnknownRiskTagError as err: + err_str = str(err) + assert foo in err_str + assert bar in err_str + assert mysterious_coffee_breaks in err_str + + def test_remove_high_risk_employee_calls_remove(sdk_with_user, profile): remove_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) sdk_with_user.detectionlists.high_risk_employee.remove.assert_called_once_with(TEST_ID) @@ -100,58 +134,128 @@ def test_remove_high_risk_employee_when_user_does_not_exist_prints_error( def test_add_risk_tags_adds_tags(sdk_with_user, profile): - add_risk_tags(sdk_with_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + add_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], + ) sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] ) def test_add_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): - add_risk_tags(sdk_with_user, profile, _EMPLOYEE, "TAG_YOU_ARE_IT GROUND_IS_LAVA") + add_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + "{} {}".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), + ) sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] ) def test_add_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): with pytest.raises(UserDoesNotExistError): - add_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + add_risk_tags( + sdk_without_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], + ) def test_add_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, caplog): with caplog.at_level(logging.ERROR): try: add_risk_tags( - sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + sdk_without_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], ) except UserDoesNotExistError: assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text +def test_add_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( + sdk_with_user, profile, generic_bad_request +): + sdk_with_user.detectionlists.add_user_risk_tags.side_effect = generic_bad_request + try: + add_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + "{} foo {} bar".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), + ) + except UnknownRiskTagError as err: + err_str = str(err) + assert "foo" in err_str + assert "bar" in err_str + + def test_remove_risk_tags_adds_tags(sdk_with_user, profile): - remove_risk_tags(sdk_with_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + remove_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], + ) sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( - TEST_ID, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] ) def test_remove_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): - remove_risk_tags(sdk_with_user, profile, _EMPLOYEE, "TAG_YOU_ARE_IT GROUND_IS_LAVA") + remove_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + "{} {}".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), + ) sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( - TEST_ID, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] ) def test_remove_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): with pytest.raises(UserDoesNotExistError): - remove_risk_tags(sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"]) + remove_risk_tags( + sdk_without_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], + ) def test_remove_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, caplog): with caplog.at_level(logging.ERROR): try: remove_risk_tags( - sdk_without_user, profile, _EMPLOYEE, ["TAG_YOU_ARE_IT", "GROUND_IS_LAVA"] + sdk_without_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], ) except UserDoesNotExistError: assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text + + +def test_remove_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( + sdk_with_user, profile, generic_bad_request +): + sdk_with_user.detectionlists.remove_user_risk_tags.side_effect = generic_bad_request + try: + remove_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + "{} foo {} bar".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), + ) + except UnknownRiskTagError as err: + err_str = str(err) + assert "foo" in err_str + assert "bar" in err_str diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 832407cfa..146a92b72 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -3,13 +3,18 @@ from code42cli import PRODUCT_NAME from code42cli.cmds.detectionlists import ( + try_handle_user_already_added_error, DetectionList, DetectionListHandlers, get_user_id, update_user, UserDoesNotExistError, + try_add_risk_tags, + try_remove_risk_tags, + UnknownRiskTagError, + UserAlreadyAddedError, ) -from code42cli.cmds.detectionlists.enums import BulkCommandType +from code42cli.cmds.detectionlists.enums import BulkCommandType, RiskTags from .conftest import TEST_ID @@ -26,6 +31,19 @@ def bulk_processor(mocker): return mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) +def test_try_handle_user_already_added_error_when_error_indicates_user_added_raises_UserAlreadyAddedError( + bad_request_for_user_already_added +): + with pytest.raises(UserAlreadyAddedError): + try_handle_user_already_added_error(bad_request_for_user_already_added, "name", "listname") + + +def test_try_handle_user_already_added_error_when_error_does_not_indicate_user_added_returns_false( + generic_bad_request +): + assert not try_handle_user_already_added_error(generic_bad_request, "name", "listname") + + def test_get_user_id_when_user_does_not_raise_error(sdk_without_user): with pytest.raises(UserDoesNotExistError): get_user_id(sdk_without_user, "risky employee") @@ -59,6 +77,30 @@ def test_update_user_updates_notes(sdk_with_user, profile): sdk_with_user.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) +def test_try_add_risk_tags_when_sdk_raises_bad_request_and_given_unknown_tags_raises_UnknownRiskTagError( + sdk, profile, generic_bad_request +): + sdk.detectionlists.add_user_risk_tags.side_effect = generic_bad_request + try: + try_add_risk_tags(sdk, profile, ["foo", RiskTags.SUSPICIOUS_SYSTEM_ACTIVITY, "bar"]) + except UnknownRiskTagError as err: + err_str = str(err) + assert "foo" in err_str + assert "bar" in err_str + + +def test_try_remove_risk_tags_when_sdk_raises_bad_request_and_given_unknown_tags_raises_UnknownRiskTagError( + sdk, profile, generic_bad_request +): + sdk.detectionlists.remove_user_risk_tags.side_effect = generic_bad_request + try: + try_remove_risk_tags(sdk, profile, ["foo", RiskTags.SUSPICIOUS_SYSTEM_ACTIVITY, "bar"]) + except UnknownRiskTagError as err: + err_str = str(err) + assert "foo" in err_str + assert "bar" in err_str + + class TestDetectionList(object): def test_load_commands_loads_expected_commands(self): detection_list = DetectionList("TestList", DetectionListHandlers()) From c36cb660d0bab9481387fdb9337ea7dc7d7dcbaa Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Thu, 14 May 2020 18:52:04 +0530 Subject: [PATCH 048/349] Feature/user alert rules (#53) --- CHANGELOG.md | 11 ++ src/code42cli/args.py | 6 +- src/code42cli/bulk.py | 8 + src/code42cli/cmds/alerts/__init__.py | 0 src/code42cli/cmds/alerts/rules/__init__.py | 0 src/code42cli/cmds/alerts/rules/commands.py | 142 ++++++++++++++++++ src/code42cli/cmds/alerts/rules/enums.py | 4 + src/code42cli/cmds/alerts/rules/user_rule.py | 74 +++++++++ src/code42cli/cmds/detectionlists/__init__.py | 8 +- src/code42cli/cmds/detectionlists/commands.py | 2 +- src/code42cli/cmds/detectionlists/enums.py | 8 - src/code42cli/main.py | 8 +- src/code42cli/util.py | 41 +++++ tests/cmds/alerts/__init__.py | 0 tests/cmds/alerts/rules/__init__.py | 0 tests/cmds/alerts/rules/conftest.py | 13 ++ tests/cmds/alerts/rules/test_user_rule.py | 57 +++++++ tests/cmds/detectionlists/test_init.py | 3 +- tests/test_main.py | 41 +++-- tests/test_util.py | 34 ++++- 20 files changed, 426 insertions(+), 34 deletions(-) create mode 100644 src/code42cli/cmds/alerts/__init__.py create mode 100644 src/code42cli/cmds/alerts/rules/__init__.py create mode 100644 src/code42cli/cmds/alerts/rules/commands.py create mode 100644 src/code42cli/cmds/alerts/rules/enums.py create mode 100644 src/code42cli/cmds/alerts/rules/user_rule.py create mode 100644 tests/cmds/alerts/__init__.py create mode 100644 tests/cmds/alerts/rules/__init__.py create mode 100644 tests/cmds/alerts/rules/conftest.py create mode 100644 tests/cmds/alerts/rules/test_user_rule.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e8dac0e..d245c3a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- `code42 alert-rules` commands: + - `add-user` with parameters `--rule-id` and `--username`. + - `remove-user` that takes a rule ID and optionally `--username`. + - `list`. + - `show` takes a rule ID. + - `bulk` with subcommands: + - `add`: that takes a csv file with rule IDs and usernames. + - `generate-template`: that creates the file template. And parameters: + - `cmd`: with options `add` and `remove`. + - `path` + - `remove`: that takes a csv file with rule IDs and usernames. - Success messages for `profile delete` and `profile update`. - Additional information in the error log file: - The full command path for the command that errored. diff --git a/src/code42cli/args.py b/src/code42cli/args.py index 229d2371b..99c2db1b7 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -2,7 +2,7 @@ import inspect -PROFILE_HELP = u"The name of the Code42 profile use when executing this command." +PROFILE_HELP = u"The name of the Code42 profile to use when executing this command." SDK_ARG_NAME = u"sdk" PROFILE_ARG_NAME = u"profile" @@ -18,6 +18,7 @@ def __init__(self, *args, **kwargs): u"help": kwargs.get(u"help"), u"options_list": list(args), u"nargs": kwargs.get(u"nargs"), + u"required": kwargs.get(u"required"), } @property @@ -36,6 +37,9 @@ def add_short_option_name(self, short_name): def as_multi_val_param(self, nargs=u"+"): self._settings[u"nargs"] = nargs + def set_required(self, required=False): + self._settings[u"required"] = required + class ArgConfigCollection(object): def __init__(self): diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 042b0292d..b2333e83e 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -8,6 +8,14 @@ from code42cli.args import SDK_ARG_NAME, PROFILE_ARG_NAME +class BulkCommandType(object): + ADD = u"add" + REMOVE = u"remove" + + def __iter__(self): + return iter([self.ADD, self.REMOVE]) + + def generate_template(handler, path=None): """Looks at the parameter names of `handler` and creates a file with the same column names. If `handler` only has one parameter that is not `sdk` or `profile`, it will create a blank file. diff --git a/src/code42cli/cmds/alerts/__init__.py b/src/code42cli/cmds/alerts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/code42cli/cmds/alerts/rules/__init__.py b/src/code42cli/cmds/alerts/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py new file mode 100644 index 000000000..0ecc573a4 --- /dev/null +++ b/src/code42cli/cmds/alerts/rules/commands.py @@ -0,0 +1,142 @@ +from code42cli.commands import Command +from code42cli.bulk import generate_template, BulkCommandType +from code42cli.cmds.alerts.rules.user_rule import ( + add_user, + remove_user, + get_rules, + add_bulk_users, + remove_bulk_users, + show_rules, +) + + +def _customize_add_arguments(argument_collection): + rule_id = argument_collection.arg_configs[u"rule_id"] + rule_id.set_help(u"Observer ID of the rule to be updated. Required.") + rule_id.set_required(True) + username = argument_collection.arg_configs[u"username"] + username.set_help(u"The username of the user to add to the alert rule. Required.") + username.set_required(True) + + +def _customize_remove_arguments(argument_collection): + rule_id = argument_collection.arg_configs[u"rule_id"] + rule_id.set_help(u"Observer ID of the rule to be updated.") + username = argument_collection.arg_configs[u"username"] + username.set_help(u"The username of the user to remove from the alert rule.") + + +def _customize_list_arguments(argument_collection): + rule_id = argument_collection.arg_configs[u"rule_id"] + rule_id.set_help(u"Observer ID of the rule.") + + +def _customize_bulk_arguments(argument_collection): + file_name = argument_collection.arg_configs[u"file_name"] + file_name.set_help( + u"The path to the csv file with columns 'rule_id,user_id' " + u"for bulk adding users to the alert rule." + ) + + +def _generate_template_file(cmd, path=None): + """Generates a template file a user would need to fill-in for bulk operating. + + Args: + cmd (str or unicode): An option from the `BulkCommandType` enum specifying which type of file to + generate. + path (str or unicode, optional): A path to put the file after it's generated. If None, will use + the current working directory. Defaults to None. + """ + handler = None + if cmd == BulkCommandType.ADD: + handler = add_user + elif cmd == BulkCommandType.REMOVE: + handler = remove_user + + generate_template(handler, path) + + +def _load_bulk_generate_template_description(argument_collection): + cmd_type = argument_collection.arg_configs[u"cmd"] + cmd_type.set_help(u"The type of command the template with be used for.") + cmd_type.set_choices(BulkCommandType()) + + +class AlertRulesBulkCommands(object): + @staticmethod + def load_commands(): + usage_prefix = u"code42 alert-rules bulk" + + generate_template_cmd = Command( + u"generate-template", + u"Generate the necessary csv template needed for bulk adding users.", + u"{} generate-template ".format(usage_prefix), + handler=_generate_template_file, + arg_customizer=_load_bulk_generate_template_description, + ) + + bulk_add = Command( + u"add", + u"Update alert rule criteria to add users and all their aliases. " + u"CSV file format: rule_id,username", + u"{} add ".format(usage_prefix), + handler=add_bulk_users, + arg_customizer=_customize_bulk_arguments, + ) + + bulk_remove = Command( + u"remove", + u"Update alert rule criteria to remove users and all their aliases. " + u"CSV file format: rule_id,username", + u"{} remove ".format(usage_prefix), + handler=remove_bulk_users, + arg_customizer=_customize_bulk_arguments, + ) + + return [generate_template_cmd, bulk_add, bulk_remove] + + +class AlertRulesCommands(object): + @staticmethod + def load_subcommands(): + usage_prefix = u"code42 alert-rules" + + add = Command( + u"add-user", + u"Update alert rule criteria to monitor user aliases against the given username.", + u"{} add-user --rule-id --username ".format(usage_prefix), + handler=add_user, + arg_customizer=_customize_add_arguments, + ) + + remove = Command( + u"remove-user", + u"Update alert rule criteria to remove a user and all their aliases.", + u"{} remove-user --username ".format(usage_prefix), + handler=remove_user, + arg_customizer=_customize_remove_arguments, + ) + + list_rules = Command( + u"list", + u"Fetch existing alert rules.", + u"{} list".format(usage_prefix), + handler=get_rules, + ) + + show = Command( + u"show", + u"Fetch configured alert-rules against the rule ID.", + u"{} show ".format(usage_prefix), + handler=show_rules, + arg_customizer=_customize_list_arguments, + ) + + bulk = Command( + u"bulk", + u"Tools for executing bulk commands.", + subcommand_loader=AlertRulesBulkCommands.load_commands, + ) + + return [add, remove, list_rules, show, bulk] diff --git a/src/code42cli/cmds/alerts/rules/enums.py b/src/code42cli/cmds/alerts/rules/enums.py new file mode 100644 index 000000000..593dd0ef5 --- /dev/null +++ b/src/code42cli/cmds/alerts/rules/enums.py @@ -0,0 +1,4 @@ +class AlertRuleTypes(object): + EXFILTRATION = u"FED_ENDPOINT_EXFILTRATION" + CLOUD_SHARE = u"FED_CLOUD_SHARE_PERMISSIONS" + FILE_TYPE_MISMATCH = u"FED_FILE_TYPE_MISMATCH" diff --git a/src/code42cli/cmds/alerts/rules/user_rule.py b/src/code42cli/cmds/alerts/rules/user_rule.py new file mode 100644 index 000000000..faeffe329 --- /dev/null +++ b/src/code42cli/cmds/alerts/rules/user_rule.py @@ -0,0 +1,74 @@ +from py42.util import format_json + +from code42cli.util import format_to_table, find_format_width +from code42cli.bulk import run_bulk_process, CSVReader +from code42cli.logger import get_main_cli_logger +from code42cli.cmds.detectionlists import get_user_id +from code42cli.cmds.alerts.rules.enums import AlertRuleTypes + + +_HEADER_KEYS_MAP = { + u"observerRuleId": u"RuleId", + u"name": u"Name", + u"severity": u"Severity", + u"type": u"Type", + u"ruleSource": u"Source", + u"isEnabled": u"Enabled", +} + + +def add_user(sdk, profile, rule_id=None, username=None): + user_id = get_user_id(sdk, username) + sdk.alerts.rules.add_user(rule_id, user_id) + + +def remove_user(sdk, profile, rule_id, username=None): + if username: + user_id = get_user_id(sdk, username) + sdk.alerts.rules.remove_user(rule_id, user_id) + else: + sdk.alerts.rules.remove_all_users(rule_id) + + +def _get_rules_metadata(sdk, rule_id=None): + rules_generator = sdk.alerts.rules.get_all() + selected_rules = [rule for rules in rules_generator for rule in rules[u"ruleMetadata"]] + if rule_id: + selected_rules = [rule for rule in selected_rules if rule[u"observerRuleId"] == rule_id] + return selected_rules + + +def get_rules(sdk, profile): + selected_rules = _get_rules_metadata(sdk) + rows, column_size = find_format_width(selected_rules, _HEADER_KEYS_MAP) + format_to_table(rows, column_size) + + +def add_bulk_users(sdk, profile, file_name): + run_bulk_process( + file_name, lambda rule_id, username: add_user(sdk, profile, rule_id, username), CSVReader() + ) + + +def remove_bulk_users(sdk, profile, file_name): + run_bulk_process( + file_name, + lambda rule_id, username: remove_user(sdk, profile, rule_id, username), + CSVReader(), + ) + + +def show_rules(sdk, profile, rule_id): + selected_rule = _get_rules_metadata(sdk, rule_id) + rule_detail = None + if len(selected_rule): + rule_type = selected_rule[0][u"type"] + if rule_type == AlertRuleTypes.EXFILTRATION: + rule_detail = sdk.alerts.rules.exfiltration.get(rule_id) + elif rule_type == AlertRuleTypes.CLOUD_SHARE: + rule_detail = sdk.alerts.rules.cloudshare.get(rule_id) + elif rule_type == AlertRuleTypes.FILE_TYPE_MISMATCH: + rule_detail = sdk.alerts.rules.filetypemismatch.get(rule_id) + if rule_detail: + logger = get_main_cli_logger() + logger.print_info(format_json(rule_detail.text)) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 408bef8bb..b55fc4c6a 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -4,12 +4,8 @@ from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory from code42cli.bulk import generate_template, run_bulk_process, CSVReader, FlatFileReader from code42cli.logger import get_main_cli_logger -from code42cli.cmds.detectionlists.enums import ( - BulkCommandType, - DetectionLists, - DetectionListUserKeys, - RiskTags, -) +from code42cli.bulk import BulkCommandType +from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags class UserAlreadyAddedError(Exception): diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py index dbd81a355..49d4e4171 100644 --- a/src/code42cli/cmds/detectionlists/commands.py +++ b/src/code42cli/cmds/detectionlists/commands.py @@ -1,4 +1,4 @@ -from code42cli.cmds.detectionlists.enums import BulkCommandType +from code42cli.bulk import BulkCommandType from code42cli.commands import Command diff --git a/src/code42cli/cmds/detectionlists/enums.py b/src/code42cli/cmds/detectionlists/enums.py index f36dbf6ab..5f9887da7 100644 --- a/src/code42cli/cmds/detectionlists/enums.py +++ b/src/code42cli/cmds/detectionlists/enums.py @@ -3,14 +3,6 @@ class DetectionLists(object): HIGH_RISK_EMPLOYEE = u"high-risk-employee" -class BulkCommandType(object): - ADD = u"add" - REMOVE = u"remove" - - def __iter__(self): - return iter([self.ADD, self.REMOVE]) - - class DetectionListUserKeys(object): CLOUD_ALIAS = u"cloud_alias" USERNAME = u"username" diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 2ce19bb00..6a367b1ab 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -11,6 +11,8 @@ from code42cli.cmds.securitydata import main as secmain from code42cli.commands import Command from code42cli.invoker import CommandInvoker +from code42cli.cmds.alerts.rules.commands import AlertRulesCommands + # If on Windows, configure console session to handle ANSI escape sequences correctly # source: https://bugs.python.org/issue29059 @@ -37,7 +39,6 @@ def main(): def _load_top_commands(): detection_lists_description = u"For adding and removing employees from the {} detection list." - return [ Command( u"profile", u"For managing Code42 settings.", subcommand_loader=profile.load_subcommands @@ -57,6 +58,11 @@ def _load_top_commands(): detection_lists_description.format(u"high risk employee"), subcommand_loader=hre.load_subcommands, ), + Command( + u"alert-rules", + u"Manage alert rules", + subcommand_loader=AlertRulesCommands.load_subcommands, + ), ] diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 738326c99..74b691cd3 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,8 +1,11 @@ +from __future__ import print_function import sys from os import makedirs, path from code42cli.compat import open +_PADDING_SIZE = 3 + def get_input(prompt): """Uses correct input function based on Python version.""" @@ -47,3 +50,41 @@ def get_url_parts(url_str): if len(parts) > 1 and parts[1] != u"": port = int(parts[1]) return parts[0], port + + +def find_format_width(record, header): + """Fetches needed keys/items to be displayed based on header keys. + + Finds the largest string against each column so as to decide the padding size for the column. + + Args: + record (list of dict), data to be formatted. + header (dict), key-value where keys should map to keys of record dict and + value is the corresponding column name to be displayed on the cli. + + Returns: + tuple (list of dict, dict), i.e Filtered records, padding size of columns. + """ + rows = [header] + + # Set default max width items to column names + max_width_item = dict(header.items()) + for record_row in record: + row = {} + for header_key in header.keys(): + row[header_key] = record_row[header_key] + max_width_item[header_key] = max( + max_width_item[header_key], str(record_row[header_key]), key=len + ) + rows.append(row) + column_size = {key: len(value) for key, value in max_width_item.items()} + return rows, column_size + + +def format_to_table(rows, column_size): + """Prints result in left justified format in a tabular form. + """ + for row in rows: + for key in row.keys(): + print(repr(row[key]).ljust(column_size[key] + _PADDING_SIZE), end=u" ") + print(u"") diff --git a/tests/cmds/alerts/__init__.py b/tests/cmds/alerts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cmds/alerts/rules/__init__.py b/tests/cmds/alerts/rules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cmds/alerts/rules/conftest.py b/tests/cmds/alerts/rules/conftest.py new file mode 100644 index 000000000..074b04422 --- /dev/null +++ b/tests/cmds/alerts/rules/conftest.py @@ -0,0 +1,13 @@ +import pytest + + +@pytest.fixture +def alert_rules_sdk(sdk): + sdk.alerts.rules.add_user.return_value = {} + sdk.alerts.rules.remove_user.return_value = {} + sdk.alerts.rules.remove_all_users.return_value = {} + sdk.alerts.rules.get_all.return_value = {} + sdk.alerts.rules.exfiltration.get.return_value = {} + sdk.alerts.rules.cloudshare.get.return_value = {} + sdk.alerts.rules.filetypemismatch.get.return_value = {} + return sdk diff --git a/tests/cmds/alerts/rules/test_user_rule.py b/tests/cmds/alerts/rules/test_user_rule.py new file mode 100644 index 000000000..ea777d546 --- /dev/null +++ b/tests/cmds/alerts/rules/test_user_rule.py @@ -0,0 +1,57 @@ +import pytest + +from code42cli.cmds.alerts.rules.user_rule import add_user, remove_user, get_rules, show_rules + +TEST_RULE_ID = u"rule-id" +TEST_USER_ID = u"test-user-id" +TEST_USERNAME = "test@code42.com" + +TEST_GET_ALL_RESPONSE_EXFILTRATION = [ + {u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_ENDPOINT_EXFILTRATION"}]} +] + +TEST_GET_ALL_RESPONSE_CLOUD_SHARE = [ + {u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_CLOUD_SHARE_PERMISSIONS"}]} +] + + +TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH = [ + {u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_FILE_TYPE_MISMATCH"}]} +] + + +def test_add_user_adds_user_list_to_alert_rules(alert_rules_sdk, profile): + alert_rules_sdk.users.get_by_username.return_value = {u"users": [{u"userUid": TEST_USER_ID}]} + add_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) + alert_rules_sdk.alerts.rules.add_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) + + +def test_remove_user_removes_user_list_from_alert_rules(alert_rules_sdk, profile): + alert_rules_sdk.users.get_by_username.return_value = {u"users": [{u"userUid": TEST_USER_ID}]} + remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) + alert_rules_sdk.alerts.rules.remove_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) + remove_user(alert_rules_sdk, profile, TEST_RULE_ID) + alert_rules_sdk.alerts.rules.remove_all_users.assert_called_once_with(TEST_RULE_ID) + + +def test_get_rules_gets_alert_rules(alert_rules_sdk, profile): + get_rules(alert_rules_sdk, profile) + assert alert_rules_sdk.alerts.rules.get_all.call_count == 1 + + +def test_show_rules_calls_correct_rule_property(alert_rules_sdk, profile): + alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_EXFILTRATION + show_rules(alert_rules_sdk, profile, TEST_RULE_ID) + alert_rules_sdk.alerts.rules.exfiltration.get.assert_called_once_with(TEST_RULE_ID) + + +def test_show_rules_calls_correct_rule_property_cloud_share(alert_rules_sdk, profile): + alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_CLOUD_SHARE + show_rules(alert_rules_sdk, profile, TEST_RULE_ID) + alert_rules_sdk.alerts.rules.cloudshare.get.assert_called_once_with(TEST_RULE_ID) + + +def test_show_rules_calls_correct_rule_property_file_type_mismatch(alert_rules_sdk, profile): + alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH + show_rules(alert_rules_sdk, profile, TEST_RULE_ID) + alert_rules_sdk.alerts.rules.filetypemismatch.get.assert_called_once_with(TEST_RULE_ID) diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 146a92b72..5ea4e6987 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -14,7 +14,8 @@ UnknownRiskTagError, UserAlreadyAddedError, ) -from code42cli.cmds.detectionlists.enums import BulkCommandType, RiskTags +from code42cli.bulk import BulkCommandType +from code42cli.cmds.detectionlists.enums import RiskTags from .conftest import TEST_ID diff --git a/tests/test_main.py b/tests/test_main.py index e50a9c2b8..a03d74c30 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,25 +2,36 @@ from code42cli.main import main -def test_securitydata_commands_load(capsys, mocker): - mocker.patch("sys.argv", ["code42", "security-data", "print", "-h"]) - success = False +def _execute_test(capsys, assert_command, assert_value=False): try: main() except SystemExit: - success = True + assert_value = True capture = capsys.readouterr() - assert "print" in capture.out - assert success + assert assert_command in capture.out + assert assert_value + + +def test_securitydata_commands_load(capsys, mocker): + mocker.patch("sys.argv", [u"code42", u"security-data", u"print", u"-h"]) + _execute_test(capsys, u"print") def test_profile_commands_load(capsys, mocker): - mocker.patch("sys.argv", ["code42", "profile", "show", "-h"]) - success = False - try: - main() - except SystemExit: - success = True - capture = capsys.readouterr() - assert "show" in capture.out - assert success + mocker.patch("sys.argv", [u"code42", u"profile", u"show", u"-h"]) + _execute_test(capsys, u"show") + + +def test_departing_employee_commands_load(capsys, mocker): + mocker.patch("sys.argv", [u"code42", u"departing-employee", u"add", u"-h"]) + _execute_test(capsys, u"add") + + +def test_high_risk_employee_commands_load(capsys, mocker): + mocker.patch("sys.argv", [u"code42", u"high-risk-employee", u"bulk", u"-h"]) + _execute_test(capsys, u"bulk") + + +def test_alert_rules_commands_load(capsys, mocker): + mocker.patch("sys.argv", [u"code42", u"alert-rules", u"bulk", u"add", u"-h"]) + _execute_test(capsys, u"add") diff --git a/tests/test_util.py b/tests/test_util.py index f5c0eac03..88a0f67d8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,7 +1,10 @@ import pytest from code42cli import PRODUCT_NAME -from code42cli.util import does_user_agree, get_url_parts +from code42cli.util import does_user_agree, get_url_parts, find_format_width + + +TEST_HEADER = {u"key1": u"Column 1", u"key2": u"Column 10", u"key3": u"Column 100"} @pytest.fixture @@ -34,3 +37,32 @@ def test_does_user_agree_when_user_says_capital_y_returns_true(mock_input): def test_does_user_agree_when_user_says_n_returns_false(mock_input): mock_input.return_value = "n" assert not does_user_agree("Test Prompt") + + +def test_find_format_width_when_zero_records_sets_width_to_header_length(): + _, column_width = find_format_width([], TEST_HEADER) + assert column_width[u"key1"] == len(TEST_HEADER[u"key1"]) + assert column_width[u"key2"] == len(TEST_HEADER[u"key2"]) + assert column_width[u"key3"] == len(TEST_HEADER[u"key3"]) + + +def test_find_format_width_when_records_sets_width_to_greater_of_data_or_header_length(): + report = [ + {u"key1": u"test 1", u"key2": u"value xyz test", u"key3": u"test test test test"}, + {u"key1": u"1", u"key2": u"value xyz", u"key3": u"test test test test"}, + ] + _, column_width = find_format_width(report, TEST_HEADER) + assert column_width[u"key1"] == len(TEST_HEADER[u"key1"]) + assert column_width[u"key2"] == len(report[0][u"key2"]) + assert column_width[u"key3"] == len(report[1][u"key3"]) + + +def test_find_format_width_filters_keys_not_present_in_header(): + report = [ + {u"key1": u"test 1", u"key2": u"value xyz test", u"key3": u"test test test test"}, + {u"key1": u"1", u"key2": u"value xyz", u"key3": u"test test test test"}, + ] + header_with_subset_keys = {u"key1": u"Column 1", u"key3": u"Column 100"} + result, _ = find_format_width(report, header_with_subset_keys) + for item in result: + assert u"key2" not in item.keys() From 0ae6af8c823fba648a790cfe6bf7c98aa7a2d0b7 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 14 May 2020 08:27:08 -0500 Subject: [PATCH 049/349] Feature/INTEG-950 alert polling (#51) Co-authored-by: Alan Grgic --- CHANGELOG.md | 1 + setup.py | 4 +- src/code42cli/args.py | 1 + src/code42cli/cmds/alerts/extraction.py | 95 ++++ src/code42cli/cmds/alerts/main.py | 189 +++++++ src/code42cli/cmds/alerts/util.py | 14 + src/code42cli/cmds/detectionlists/__init__.py | 2 +- .../{shared => search_shared}/__init__.py | 0 src/code42cli/cmds/search_shared/args.py | 52 ++ .../{shared => search_shared}/cursor_store.py | 75 ++- src/code42cli/cmds/search_shared/enums.py | 177 ++++++ .../cmds/search_shared/extraction.py | 102 ++++ .../logger_factory.py | 3 +- .../cmds/securitydata/date_helper.py | 44 -- src/code42cli/cmds/securitydata/enums.py | 83 --- src/code42cli/cmds/securitydata/extraction.py | 163 +----- src/code42cli/cmds/securitydata/main.py | 102 ++-- src/code42cli/compat.py | 4 + src/code42cli/date_helper.py | 9 +- src/code42cli/main.py | 6 + src/code42cli/profile.py | 7 +- tests/cmds/alerts/test_cursor_store.py | 87 +++ tests/cmds/alerts/test_extraction.py | 440 +++++++++++++++ tests/cmds/alerts/test_main.py | 35 ++ tests/cmds/alerts/test_util.py | 54 ++ tests/cmds/{securitydata => }/conftest.py | 27 +- tests/cmds/search_shared/__init__.py | 0 .../test_date_helper.py | 88 ++- .../test_logger_factory.py | 6 +- tests/cmds/securitydata/test_cursor_store.py | 34 +- tests/cmds/securitydata/test_extraction.py | 529 +++++++++--------- tests/cmds/securitydata/test_main.py | 18 +- tests/conftest.py | 83 ++- tests/test_profile.py | 14 +- 34 files changed, 1817 insertions(+), 731 deletions(-) create mode 100644 src/code42cli/cmds/alerts/extraction.py create mode 100644 src/code42cli/cmds/alerts/main.py create mode 100644 src/code42cli/cmds/alerts/util.py rename src/code42cli/cmds/{shared => search_shared}/__init__.py (100%) create mode 100644 src/code42cli/cmds/search_shared/args.py rename src/code42cli/cmds/{shared => search_shared}/cursor_store.py (74%) create mode 100644 src/code42cli/cmds/search_shared/enums.py create mode 100644 src/code42cli/cmds/search_shared/extraction.py rename src/code42cli/cmds/{securitydata => search_shared}/logger_factory.py (97%) delete mode 100644 src/code42cli/cmds/securitydata/date_helper.py delete mode 100644 src/code42cli/cmds/securitydata/enums.py create mode 100644 tests/cmds/alerts/test_cursor_store.py create mode 100644 tests/cmds/alerts/test_extraction.py create mode 100644 tests/cmds/alerts/test_main.py create mode 100644 tests/cmds/alerts/test_util.py rename tests/cmds/{securitydata => }/conftest.py (68%) create mode 100644 tests/cmds/search_shared/__init__.py rename tests/cmds/{securitydata => search_shared}/test_date_helper.py (53%) rename tests/cmds/{securitydata => search_shared}/test_logger_factory.py (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d245c3a71..cddbbac46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- Ability to search/poll for alerts with checkpointing and sending to console, a file, or a server in json format. - `code42 alert-rules` commands: - `add-user` with parameters `--rule-id` and `--username`. - `remove-user` that takes a rule ID and optionally `--username`. diff --git a/setup.py b/setup.py index 600fe9ed2..17142affa 100644 --- a/setup.py +++ b/setup.py @@ -21,10 +21,10 @@ package_dir={"": "src"}, python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ - "c42eventextractor==0.2.9", + "c42eventextractor==0.3.0b1", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42>=1.1.1", + "py42>=1.1.3", ], license="MIT", include_package_data=True, diff --git a/src/code42cli/args.py b/src/code42cli/args.py index 99c2db1b7..b1e1139fd 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -18,6 +18,7 @@ def __init__(self, *args, **kwargs): u"help": kwargs.get(u"help"), u"options_list": list(args), u"nargs": kwargs.get(u"nargs"), + u"metavar": kwargs.get(u"metavar"), u"required": kwargs.get(u"required"), } diff --git a/src/code42cli/cmds/alerts/extraction.py b/src/code42cli/cmds/alerts/extraction.py new file mode 100644 index 000000000..e3adc0563 --- /dev/null +++ b/src/code42cli/cmds/alerts/extraction.py @@ -0,0 +1,95 @@ +from c42eventextractor.extractors import AlertExtractor +from py42.sdk.queries.alerts.filters import ( + Actor, + AlertState, + Severity, + DateObserved, + Description, + RuleName, + RuleId, + RuleType, +) + +import code42cli.cmds.search_shared.enums as enums +import code42cli.errors as errors +from code42cli.cmds.search_shared.cursor_store import AlertCursorStore +from code42cli.cmds.search_shared.extraction import ( + verify_begin_date_requirements, + create_handlers, + exit_if_advanced_query_used_with_other_search_args, + create_time_range_filter, +) +from code42cli.logger import get_main_cli_logger + +logger = get_main_cli_logger() + + +def extract(sdk, profile, output_logger, args): + """Extracts alerts using the given command-line arguments. + + Args: + sdk (py42.sdk.SDKClient): The py42 sdk. + profile (Code42Profile): The profile under which to execute this command. + output_logger (Logger): The logger specified by which subcommand you use. For example, + print: uses a logger that streams to stdout. + write-to: uses a logger that logs to a file. + send-to: uses a logger that sends logs to a server. + args: Command line args used to build up alert query filters. + """ + store = AlertCursorStore(profile.name) if args.incremental else None + handlers = create_handlers(output_logger, store, event_key=u"alerts", sdk=sdk) + extractor = AlertExtractor(sdk, handlers) + if args.advanced_query: + exit_if_advanced_query_used_with_other_search_args(args) + extractor.extract_advanced(args.advanced_query) + else: + verify_begin_date_requirements(args, store) + _verify_alert_state(args.state) + _verify_alert_severity(args.severity) + filters = _create_alert_filters(args) + extractor.extract(*filters) + if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: + logger.print_info(u"No results found\n") + + +def _verify_alert_state(alert_state): + options = list(enums.AlertState()) + if alert_state and alert_state not in options: + logger.print_and_log_error( + u"'{0}' is not a valid alert state, options are {1}.".format(alert_state, options) + ) + exit(1) + + +def _verify_alert_severity(severity): + if severity is None: + return + options = list(enums.AlertSeverity()) + for s in severity: + if s not in options: + logger.print_and_log_error( + u"'{0}' is not a valid alert severity, options are {1}".format(s, options) + ) + exit(1) + + +def _create_alert_filters(args): + filters = [] + alert_timestamp_filter = create_time_range_filter(DateObserved, args.begin, args.end) + not alert_timestamp_filter or filters.append(alert_timestamp_filter) + not args.actor or filters.append(Actor.is_in(args.actor)) + not args.actor_contains or [filters.append(Actor.contains(arg)) for arg in args.actor_contains] + not args.exclude_actor or filters.append(Actor.not_in(args.exclude_actor)) + not args.exclude_actor_contains or [ + filters.append(Actor.not_contains(arg)) for arg in args.exclude_actor_contains + ] + not args.rule_name or filters.append(RuleName.is_in(args.rule_name)) + not args.exclude_rule_name or filters.append(RuleName.not_in(args.exclude_rule_name)) + not args.rule_id or filters.append(RuleId.is_in(args.rule_id)) + not args.exclude_rule_id or filters.append(RuleId.not_in(args.exclude_rule_id)) + not args.rule_type or filters.append(RuleType.is_in(args.rule_type)) + not args.exclude_rule_type or filters.append(RuleType.not_in(args.exclude_rule_type)) + not args.description or filters.append(Description.contains(args.description)) + not args.severity or filters.append(Severity.is_in(args.severity)) + not args.state or filters.append(AlertState.eq(args.state)) + return filters diff --git a/src/code42cli/cmds/alerts/main.py b/src/code42cli/cmds/alerts/main.py new file mode 100644 index 000000000..3cc8c6f9c --- /dev/null +++ b/src/code42cli/cmds/alerts/main.py @@ -0,0 +1,189 @@ +from code42cli.args import ArgConfig +from code42cli.commands import Command +from code42cli.cmds.alerts.extraction import extract +from code42cli.cmds.search_shared import args, logger_factory +from code42cli.cmds.search_shared.enums import ( + AlertFilterArguments, + AlertState, + AlertSeverity, + ServerProtocol, + RuleType, +) +from code42cli.cmds.search_shared.cursor_store import AlertCursorStore + + +def load_subcommands(): + """Sets up the `alerts` subcommand with all of its subcommands.""" + usage_prefix = u"code42 alerts" + + print_func = Command( + u"print", + u"Print alerts to stdout", + u"{} {}".format(usage_prefix, u"print "), + handler=print_out, + arg_customizer=_load_search_args, + use_single_arg_obj=True, + ) + + write = Command( + u"write-to", + u"Write alerts to the file with the given name.", + u"{} {}".format(usage_prefix, u"write-to "), + handler=write_to, + arg_customizer=_load_write_to_args, + use_single_arg_obj=True, + ) + + send = Command( + u"send-to", + u"Send alerts to the given server address.", + u"{} {}".format(usage_prefix, u"send-to "), + handler=send_to, + arg_customizer=_load_send_to_args, + use_single_arg_obj=True, + ) + + clear = Command( + u"clear-checkpoint", + u"Remove the saved alert checkpoint from 'incremental' (-i) mode.", + u"{} {}".format(usage_prefix, u"clear-checkpoint "), + handler=clear_checkpoint, + ) + + return [print_func, write, send, clear] + + +def clear_checkpoint(sdk, profile): + """Removes the stored checkpoint that keeps track of the last alert retrieved for the given profile.. + To use, run `code42 alerts clear-checkpoint`. + This affects `incremental` mode by causing it to behave like it has never been run before. + """ + AlertCursorStore(profile.name).replace_stored_cursor_timestamp(None) + + +def print_out(sdk, profile, args): + """Activates 'print' command. It gets alerts and prints them to stdout.""" + logger = logger_factory.get_logger_for_stdout(args.format) + extract(sdk, profile, logger, args) + + +def write_to(sdk, profile, args): + """Activates 'write-to' command. It gets alerts and writes them to the given file.""" + logger = logger_factory.get_logger_for_file(args.output_file, args.format) + extract(sdk, profile, logger, args) + + +def send_to(sdk, profile, args): + """Activates 'send-to' command. It getsalerts and logs them to the given server.""" + logger = logger_factory.get_logger_for_server(args.server, args.protocol, args.format) + extract(sdk, profile, logger, args) + + +def _load_write_to_args(arg_collection): + output_file = ArgConfig(u"output_file", help=u"The name of the local file to send output to.") + arg_collection.append(u"output_file", output_file) + _load_search_args(arg_collection) + + +def _load_send_to_args(arg_collection): + send_to_args = { + u"server": ArgConfig(u"server", help=u"The server address to send output to."), + u"protocol": ArgConfig( + u"-p", + u"--protocol", + choices=ServerProtocol(), + default=ServerProtocol.UDP, + help=u"Protocol used to send logs to server.", + ), + } + + arg_collection.extend(send_to_args) + _load_search_args(arg_collection) + + +def _load_search_args(arg_collection): + filter_args = { + AlertFilterArguments.SEVERITY: ArgConfig( + u"--{}".format(AlertFilterArguments.SEVERITY), + nargs=u"+", + help=u"Filter alerts by severity. Defaults to returning all severities. Available choices={0}".format( + list(AlertSeverity()) + ), + ), + AlertFilterArguments.STATE: ArgConfig( + u"--{}".format(AlertFilterArguments.STATE), + help=u"Filter alerts by state. Defaults to returning all states. Available choices={0}".format( + list(AlertState()) + ), + ), + AlertFilterArguments.ACTOR: ArgConfig( + u"--{}".format(AlertFilterArguments.ACTOR.replace("_", "-")), + metavar=u"ACTOR", + help=u"Filter alerts by including the given actor(s) who triggered the alert. Args must match actor username exactly.", + nargs=u"+", + ), + AlertFilterArguments.ACTOR_CONTAINS: ArgConfig( + u"--{}".format(AlertFilterArguments.ACTOR_CONTAINS.replace("_", "-")), + metavar=u"ACTOR", + help=u"Filter alerts by including actor(s) whose username contains the given string.", + nargs=u"+", + ), + AlertFilterArguments.EXCLUDE_ACTOR: ArgConfig( + u"--{}".format(AlertFilterArguments.EXCLUDE_ACTOR.replace("_", "-")), + metavar=u"ACTOR", + help=u"Filter alerts by excluding the given actor(s) who triggered the alert. Args must match actor username exactly.", + nargs=u"+", + ), + AlertFilterArguments.EXCLUDE_ACTOR_CONTAINS: ArgConfig( + u"--{}".format(AlertFilterArguments.EXCLUDE_ACTOR_CONTAINS.replace("_", "-")), + metavar=u"ACTOR", + help=u"Filter alerts by excluding actor(s) whose username contains the given string.", + nargs=u"+", + ), + AlertFilterArguments.RULE_NAME: ArgConfig( + u"--{}".format(AlertFilterArguments.RULE_NAME.replace("_", "-")), + metavar=u"RULE_NAME", + help=u"Filter alerts by including the given rule name(s).", + nargs=u"+", + ), + AlertFilterArguments.EXCLUDE_RULE_NAME: ArgConfig( + u"--{}".format(AlertFilterArguments.EXCLUDE_RULE_NAME.replace("_", "-")), + metavar=u"RULE_NAME", + help=u"Filter alerts by excluding the given rule name(s).", + nargs=u"+", + ), + AlertFilterArguments.RULE_ID: ArgConfig( + u"--{}".format(AlertFilterArguments.RULE_ID.replace("_", "-")), + metavar=u"RULE_ID", + help=u"Filter alerts by including the given rule id(s).", + nargs=u"+", + ), + AlertFilterArguments.EXCLUDE_RULE_ID: ArgConfig( + u"--{}".format(AlertFilterArguments.EXCLUDE_RULE_ID.replace("_", "-")), + metavar=u"RULE_ID", + help=u"Filter alerts by excluding the given rule id(s).", + nargs=u"+", + ), + AlertFilterArguments.RULE_TYPE: ArgConfig( + u"--{}".format(AlertFilterArguments.RULE_TYPE.replace("_", "-")), + metavar=u"RULE_TYPE", + help=u"Filter alerts by including the given rule type(s). Available choices={0}".format( + list(RuleType()) + ), + nargs=u"+", + ), + AlertFilterArguments.EXCLUDE_RULE_TYPE: ArgConfig( + u"--{}".format(AlertFilterArguments.EXCLUDE_RULE_TYPE.replace("_", "-")), + metavar=u"RULE_TYPE", + help=u"Filter alerts by excluding the given rule type(s). Available choices={0}".format( + list(RuleType()) + ), + nargs=u"+", + ), + AlertFilterArguments.DESCRIPTION: ArgConfig( + u"--{}".format(AlertFilterArguments.DESCRIPTION), + help=u"Filter alerts by description. Does fuzzy search by default.", + ), + } + search_args = args.create_search_args(search_for=u"alerts", filter_args=filter_args) + arg_collection.extend(search_args) diff --git a/src/code42cli/cmds/alerts/util.py b/src/code42cli/cmds/alerts/util.py new file mode 100644 index 000000000..fd63200fb --- /dev/null +++ b/src/code42cli/cmds/alerts/util.py @@ -0,0 +1,14 @@ +from code42cli.compat import range + +_BATCH_SIZE = 500 + + +def get_alert_details(sdk, alert_summary_list): + alert_ids = [alert[u"id"] for alert in alert_summary_list] + batches = [alert_ids[i : i + _BATCH_SIZE] for i in range(0, len(alert_ids), _BATCH_SIZE)] + results = [] + for batch in batches: + r = sdk.alerts.get_details(batch) + results.extend(r[u"alerts"]) + results = sorted(results, key=lambda x: x[u"createdAt"], reverse=True) + return results diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index b55fc4c6a..c6dbccb77 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -59,7 +59,7 @@ def __init__(self, add=None, remove=None, load_add=None): class DetectionList(object): """An object representing a Code42 detection list. Use this class by passing in handlers for adding and removing employees. This class will handle the bulk-related commands and some - shared help texts. + search_shared help texts. Args: list_name (str or unicode): An option from the DetectionLists enum. For convenience, use one of the diff --git a/src/code42cli/cmds/shared/__init__.py b/src/code42cli/cmds/search_shared/__init__.py similarity index 100% rename from src/code42cli/cmds/shared/__init__.py rename to src/code42cli/cmds/search_shared/__init__.py diff --git a/src/code42cli/cmds/search_shared/args.py b/src/code42cli/cmds/search_shared/args.py new file mode 100644 index 000000000..4859fd108 --- /dev/null +++ b/src/code42cli/cmds/search_shared/args.py @@ -0,0 +1,52 @@ +from code42cli.cmds.search_shared.enums import SearchArguments, OutputFormat, AlertOutputFormat +from code42cli.args import ArgConfig + + +def create_search_args(search_for, filter_args): + search_args = { + SearchArguments.ADVANCED_QUERY: ArgConfig( + u"--{}".format(SearchArguments.ADVANCED_QUERY.replace(u"_", u"-")), + metavar=u"QUERY_JSON", + help=u"A raw JSON {0} query. " + u"Useful for when the provided query parameters do not satisfy your requirements.\n" + u"WARNING: Using advanced queries is incompatible with other query-building args.".format( + search_for + ), + ), + SearchArguments.BEGIN_DATE: ArgConfig( + u"-b", + u"--{}".format(SearchArguments.BEGIN_DATE), + metavar=u"DATE", + help=u"The beginning of the date range in which to look for {1}, " + u"can be a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format " + u"where the 'time' portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') " + u"or a short value representing days (30d), hours (24h) or minutes (15m) from current " + u"time.".format(u"beginning", search_for), + ), + SearchArguments.END_DATE: ArgConfig( + u"-e", + u"--{}".format(SearchArguments.END_DATE), + metavar=u"DATE", + help=u"The end of the date range in which to look for {0}, " + u"argument format options are the same as --begin.".format(search_for), + ), + } + format_enum = AlertOutputFormat() if search_for == "alerts" else OutputFormat() + format_and_incremental_args = { + u"format": ArgConfig( + u"-f", + u"--format", + choices=format_enum, + default=format_enum.JSON, + help=u"The format used for outputting {0}.".format(search_for), + ), + u"incremental": ArgConfig( + u"-i", + u"--incremental", + action=u"store_true", + help=u"Only get {0} that were not previously retrieved.".format(search_for), + ), + } + search_args.update(filter_args) + search_args.update(format_and_incremental_args) + return search_args diff --git a/src/code42cli/cmds/shared/cursor_store.py b/src/code42cli/cmds/search_shared/cursor_store.py similarity index 74% rename from src/code42cli/cmds/shared/cursor_store.py rename to src/code42cli/cmds/search_shared/cursor_store.py index f8837bb97..d46708a98 100644 --- a/src/code42cli/cmds/shared/cursor_store.py +++ b/src/code42cli/cmds/search_shared/cursor_store.py @@ -4,8 +4,6 @@ from code42cli.util import get_user_project_path -_INSERTION_TIMESTAMP_FIELD_NAME = u"insertionTimestamp" - class BaseCursorStore(object): _PRIMARY_KEY_COLUMN_NAME = u"cursor_id" @@ -14,9 +12,11 @@ def __init__(self, db_table_name, db_file_path=None): self._table_name = db_table_name if db_file_path is None: db_path = get_user_project_path(u"db") - db_file_path = u"{0}/{1}.db".format(db_path, self._table_name) + db_file_path = u"{0}/file_event_checkpoints.db".format(db_path) self._connection = sqlite3.connect(db_file_path) + if self._is_empty(): + self._init_table() def _get(self, columns, primary_key): query = u"SELECT {0} FROM {1} WHERE {2}=?" @@ -69,27 +69,28 @@ def _is_empty(self): if query_result: return int(query_result[0]) <= 0 + def _init_table(self): + columns = u"{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, self._timestamp_column_name) + create_table_query = u"CREATE TABLE {0} ({1})".format(self._table_name, columns) + with self._connection as conn: + conn.execute(create_table_query) -class FileEventCursorStore(BaseCursorStore): - def __init__(self, profile_name, db_file_path=None): - self._primary_key = profile_name - super(FileEventCursorStore, self).__init__(u"file_event_checkpoints", db_file_path) - if self._is_empty(): - self._init_table() - if not self._row_exists(self._primary_key): - self._insert_new_row() + def _insert_new_row(self): + insert_query = u"INSERT INTO {0} VALUES(?, null)".format(self._table_name) + with self._connection as conn: + conn.execute(insert_query, (self._primary_key,)) - def get_stored_insertion_timestamp(self): - """Gets the last stored insertion timestamp.""" - rows = self._get(_INSERTION_TIMESTAMP_FIELD_NAME, self._primary_key) + def get_stored_cursor_timestamp(self): + """Gets the last stored date observed timestamp.""" + rows = self._get(self._timestamp_column_name, self._primary_key) if rows and rows[0]: return rows[0][0] - def replace_stored_insertion_timestamp(self, new_insertion_timestamp): - """Replaces the last stored insertion timestamp with the given one.""" + def replace_stored_cursor_timestamp(self, new_date_observed_timestamp): + """Replaces the last stored date observed timestamp with the given one.""" self._set( - column_name=_INSERTION_TIMESTAMP_FIELD_NAME, - new_value=new_insertion_timestamp, + column_name=self._timestamp_column_name, + new_value=new_date_observed_timestamp, primary_key=self._primary_key, ) @@ -97,17 +98,37 @@ def clean(self): """Removes profile cursor data from store.""" self._delete(self._primary_key) - def _init_table(self): - columns = u"{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, _INSERTION_TIMESTAMP_FIELD_NAME) - create_table_query = u"CREATE TABLE {0} ({1})".format(self._table_name, columns) - with self._connection as conn: - conn.execute(create_table_query) - def _insert_new_row(self): - insert_query = u"INSERT INTO {0} VALUES(?, null)".format(self._table_name) - with self._connection as conn: - conn.execute(insert_query, (self._primary_key,)) +class FileEventCursorStore(BaseCursorStore): + _timestamp_column_name = u"insertionTimestamp" + + def __init__(self, profile_name, db_file_path=None): + self._primary_key = profile_name + super(FileEventCursorStore, self).__init__(u"file_event_checkpoints", db_file_path) + if not self._row_exists(self._primary_key): + self._insert_new_row() + + +class AlertCursorStore(BaseCursorStore): + _timestamp_column_name = u"createdAt" + + def __init__(self, profile_name, db_file_path=None): + self._primary_key = profile_name + super(AlertCursorStore, self).__init__(u"alert_checkpoints", db_file_path) + if not self._row_exists(self._primary_key): + self._insert_new_row() def get_file_event_cursor_store(profile_name): return FileEventCursorStore(profile_name) + + +def get_alert_cursor_store(profile_name): + return AlertCursorStore(profile_name) + + +def get_all_cursor_stores_for_profile(profile_name): + return [ + FileEventCursorStore(profile_name), + AlertCursorStore(profile_name), + ] diff --git a/src/code42cli/cmds/search_shared/enums.py b/src/code42cli/cmds/search_shared/enums.py new file mode 100644 index 000000000..a21cfee92 --- /dev/null +++ b/src/code42cli/cmds/search_shared/enums.py @@ -0,0 +1,177 @@ +IS_INCREMENTAL_KEY = u"incremental" + + +class OutputFormat(object): + CEF = u"CEF" + JSON = u"JSON" + RAW = u"RAW-JSON" + + def __iter__(self): + return iter([self.CEF, self.JSON, self.RAW]) + + +class AlertOutputFormat(object): + JSON = u"JSON" + RAW = u"RAW-JSON" + + def __iter__(self): + return iter([self.JSON, self.RAW]) + + +class AlertSeverity(object): + HIGH = u"HIGH" + MEDIUM = u"MEDIUM" + LOW = u"LOW" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [self.HIGH, self.MEDIUM, self.LOW] + + +class AlertState(object): + OPEN = u"OPEN" + DISMISSED = u"RESOLVED" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [self.OPEN, self.DISMISSED] + + +class ExposureType(object): + SHARED_VIA_LINK = u"SharedViaLink" + SHARED_TO_DOMAIN = u"SharedToDomain" + APPLICATION_READ = u"ApplicationRead" + CLOUD_STORAGE = u"CloudStorage" + REMOVABLE_MEDIA = u"RemovableMedia" + IS_PUBLIC = u"IsPublic" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [ + self.SHARED_VIA_LINK, + self.SHARED_TO_DOMAIN, + self.APPLICATION_READ, + self.CLOUD_STORAGE, + self.REMOVABLE_MEDIA, + self.IS_PUBLIC, + ] + + +class RuleType(object): + ENDPOINT_EXFILTRATION = u"FedEndpointExfiltration" + CLOUD_SHARE_PERMISSIONS = u"FedCloudSharePermissions" + FILE_TYPE_MISMATCH = u"FedFileTypeMismatch" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [ + self.ENDPOINT_EXFILTRATION, + self.CLOUD_SHARE_PERMISSIONS, + self.FILE_TYPE_MISMATCH, + ] + + +class ServerProtocol(object): + TCP = u"TCP" + UDP = u"UDP" + + def __iter__(self): + return iter([self.TCP, self.UDP]) + + +class SearchArguments(object): + """These string values should match `argparse` stored parameter names. For example, for the + CLI argument `--c42-username`, the string should be `c42_username`.""" + + ADVANCED_QUERY = u"advanced_query" + BEGIN_DATE = u"begin" + END_DATE = u"end" + + def __iter__(self): + return iter([self.ADVANCED_QUERY, self.BEGIN_DATE, self.END_DATE,]) + + +class FileEventFilterArguments(SearchArguments): + EXPOSURE_TYPES = u"type" + C42_USERNAME = u"c42_username" + ACTOR = u"actor" + MD5 = u"md5" + SHA256 = u"sha256" + SOURCE = u"source" + FILE_NAME = u"file_name" + FILE_PATH = u"file_path" + PROCESS_OWNER = u"process_owner" + TAB_URL = u"tab_url" + INCLUDE_NON_EXPOSURE_EVENTS = u"include_non_exposure" + + def __iter__(self): + return iter( + [ + self.EXPOSURE_TYPES, + self.C42_USERNAME, + self.ACTOR, + self.MD5, + self.SHA256, + self.SOURCE, + self.FILE_NAME, + self.FILE_PATH, + self.PROCESS_OWNER, + self.TAB_URL, + self.INCLUDE_NON_EXPOSURE_EVENTS, + ] + ) + + +class AlertFilterArguments(object): + STATE = u"state" + SEVERITY = u"severity" + ACTOR = u"actor" + ACTOR_CONTAINS = u"actor_contains" + EXCLUDE_ACTOR = u"exclude_actor" + EXCLUDE_ACTOR_CONTAINS = u"exclude_actor_contains" + RULE_NAME = u"rule_name" + EXCLUDE_RULE_NAME = u"exclude_rule_name" + RULE_ID = u"rule_id" + EXCLUDE_RULE_ID = u"exclude_rule_id" + RULE_TYPE = u"rule_type" + EXCLUDE_RULE_TYPE = u"exclude_rule_type" + DESCRIPTION = u"description" + + def __iter__(self): + return iter( + [ + self.STATE, + self.SEVERITY, + self.ACTOR, + self.ACTOR_CONTAINS, + self.EXCLUDE_ACTOR, + self.EXCLUDE_ACTOR_CONTAINS, + self.RULE_NAME, + self.EXCLUDE_RULE_NAME, + self.RULE_ID, + self.EXCLUDE_RULE_ID, + self.RULE_TYPE, + self.EXCLUDE_RULE_TYPE, + self.DESCRIPTION, + ] + ) diff --git a/src/code42cli/cmds/search_shared/extraction.py b/src/code42cli/cmds/search_shared/extraction.py new file mode 100644 index 000000000..ba43e4ba3 --- /dev/null +++ b/src/code42cli/cmds/search_shared/extraction.py @@ -0,0 +1,102 @@ +import json + +from c42eventextractor import ExtractionHandlers +from py42.sdk.queries.query_filter import QueryFilterTimestampField + +import code42cli.errors as errors +from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp, verify_timestamp_order +from code42cli.logger import get_main_cli_logger +from code42cli.cmds.alerts.util import get_alert_details + +logger = get_main_cli_logger() + + +def begin_date_is_required(args, cursor_store): + if not args.incremental: + return True + is_required = cursor_store and cursor_store.get_stored_cursor_timestamp() is None + + # Ignore begin date when in incremental mode, it is not required, and it was passed an argument. + if not is_required and args.begin: + logger.print_info( + u"Ignoring --begin value as --incremental was passed and cursor checkpoint exists.\n" + ) + args.begin = None + return is_required + + +def verify_begin_date_requirements(args, cursor_store): + if begin_date_is_required(args, cursor_store) and not args.begin: + logger.print_and_log_error(u"'begin date' is required.\n") + logger.print_bold(u"Try using '-b' or '--begin'. Use `-h` for more info.\n") + exit(1) + + +def create_handlers(output_logger, cursor_store, event_key, sdk=None): + handlers = ExtractionHandlers() + handlers.TOTAL_EVENTS = 0 + + def handle_error(exception): + errors.ERRORED = True + if hasattr(exception, u"response") and hasattr(exception.response, u"text"): + message = u"{0}: {1}".format(exception, exception.response.text) + else: + message = exception + logger.print_and_log_error(message) + + handlers.handle_error = handle_error + + if cursor_store: + handlers.record_cursor_position = cursor_store.replace_stored_cursor_timestamp + handlers.get_cursor_position = cursor_store.get_stored_cursor_timestamp + + def handle_response(response): + response_dict = json.loads(response.text) + events = response_dict.get(event_key) + if event_key == u"alerts": + try: + events = get_alert_details(sdk, events) + except Exception as ex: + handlers.handle_error(ex) + handlers.TOTAL_EVENTS += len(events) + for event in events: + output_logger.info(event) + + handlers.handle_response = handle_response + return handlers + + +def exit_if_advanced_query_used_with_other_search_args(args): + args_dict_copy = args.__dict__.copy() + for arg in (u"advanced_query", u"format", u"sdk", u"profile"): + args_dict_copy.pop(arg) + if any(args_dict_copy.values()): + logger.print_and_log_error(u"You cannot use --advanced-query with additional search args.") + exit(1) + + +def create_time_range_filter(filter_cls, begin_date=None, end_date=None): + """Creates a filter using the given filter class (must be a subclass of + :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) and date args. Returns + `None` if both begin_date and end_date args are `None`. + + Args: + begin_date: The begin date for the range. + end_date: The end date for the range. + """ + if not issubclass(filter_cls, QueryFilterTimestampField): + raise Exception(u"filter_cls must be a subclass of QueryFilterTimestampField") + + if begin_date and end_date: + min_timestamp = parse_min_timestamp(begin_date) + max_timestamp = parse_max_timestamp(end_date) + verify_timestamp_order(min_timestamp, max_timestamp) + return filter_cls.in_range(min_timestamp, max_timestamp) + + elif begin_date and not end_date: + min_timestamp = parse_min_timestamp(begin_date) + return filter_cls.on_or_after(min_timestamp) + + elif end_date and not begin_date: + max_timestamp = parse_max_timestamp(end_date) + return filter_cls.on_or_before(max_timestamp) diff --git a/src/code42cli/cmds/securitydata/logger_factory.py b/src/code42cli/cmds/search_shared/logger_factory.py similarity index 97% rename from src/code42cli/cmds/securitydata/logger_factory.py rename to src/code42cli/cmds/search_shared/logger_factory.py index 0b36eab34..f553c5f78 100644 --- a/src/code42cli/cmds/securitydata/logger_factory.py +++ b/src/code42cli/cmds/search_shared/logger_factory.py @@ -1,5 +1,4 @@ import logging -import sys from c42eventextractor.logging.formatters import ( FileEventDictToCEFFormatter, @@ -8,7 +7,7 @@ ) from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper -from code42cli.cmds.securitydata.enums import OutputFormat +from code42cli.cmds.search_shared.enums import OutputFormat from code42cli.util import get_url_parts from code42cli.logger import ( logger_has_handlers, diff --git a/src/code42cli/cmds/securitydata/date_helper.py b/src/code42cli/cmds/securitydata/date_helper.py deleted file mode 100644 index b86e5991d..000000000 --- a/src/code42cli/cmds/securitydata/date_helper.py +++ /dev/null @@ -1,44 +0,0 @@ -from py42.sdk.queries.fileevents.filters.event_filter import EventTimestamp - -from code42cli.date_helper import DateArgumentException, parse_min_timestamp, parse_max_timestamp - - -def create_event_timestamp_filter(begin_date=None, end_date=None): - """Creates a `py42.sdk.file_event_query.event_query.EventTimestamp` filter using the given dates. - Returns None if not given a begin_date or an end_date. - Args: - begin_date: The begin date for the range. - end_date: The end date for the range. - """ - if begin_date and end_date: - min_timestamp = parse_min_timestamp(begin_date) - max_timestamp = parse_max_timestamp(end_date) - return _create_in_range_filter(min_timestamp, max_timestamp) - - elif begin_date and not end_date: - min_timestamp = parse_min_timestamp(begin_date) - return _create_on_or_after_filter(min_timestamp) - - elif end_date and not begin_date: - max_timestamp = parse_max_timestamp(end_date) - return _create_on_or_before_filter(max_timestamp) - - -def _create_in_range_filter(min_timestamp, max_timestamp): - _verify_timestamp_order(min_timestamp, max_timestamp) - return EventTimestamp.in_range(min_timestamp, max_timestamp) - - -def _verify_timestamp_order(min_timestamp, max_timestamp): - if min_timestamp is None or max_timestamp is None: - return - if min_timestamp >= max_timestamp: - raise DateArgumentException(u"Begin date cannot be after end date") - - -def _create_on_or_after_filter(min_timestamp): - return EventTimestamp.on_or_after(min_timestamp) - - -def _create_on_or_before_filter(max_timestamp): - return EventTimestamp.on_or_before(max_timestamp) diff --git a/src/code42cli/cmds/securitydata/enums.py b/src/code42cli/cmds/securitydata/enums.py deleted file mode 100644 index ecd5082f4..000000000 --- a/src/code42cli/cmds/securitydata/enums.py +++ /dev/null @@ -1,83 +0,0 @@ -IS_INCREMENTAL_KEY = u"incremental" - - -class OutputFormat(object): - CEF = "CEF" - JSON = "JSON" - RAW = "RAW-JSON" - - def __iter__(self): - return iter([self.CEF, self.JSON, self.RAW]) - - -class ExposureType(object): - SHARED_VIA_LINK = "SharedViaLink" - SHARED_TO_DOMAIN = "SharedToDomain" - APPLICATION_READ = "ApplicationRead" - CLOUD_STORAGE = "CloudStorage" - REMOVABLE_MEDIA = "RemovableMedia" - IS_PUBLIC = "IsPublic" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [ - self.SHARED_VIA_LINK, - self.SHARED_TO_DOMAIN, - self.APPLICATION_READ, - self.CLOUD_STORAGE, - self.REMOVABLE_MEDIA, - self.IS_PUBLIC, - ] - - -class ServerProtocol(object): - TCP = "TCP" - UDP = "UDP" - - def __iter__(self): - return iter([self.TCP, self.UDP]) - - -class SearchArguments(object): - """These string values should match `argparse` stored parameter names. For example, for the - CLI argument `--c42-username`, the string should be `c42_username`.""" - - ADVANCED_QUERY = u"advanced_query" - BEGIN_DATE = u"begin" - END_DATE = u"end" - EXPOSURE_TYPES = u"type" - C42_USERNAME = u"c42_username" - ACTOR = u"actor" - MD5 = u"md5" - SHA256 = u"sha256" - SOURCE = u"source" - FILE_NAME = u"file_name" - FILE_PATH = u"file_path" - PROCESS_OWNER = u"process_owner" - TAB_URL = u"tab_url" - INCLUDE_NON_EXPOSURE_EVENTS = u"include_non_exposure" - - def __iter__(self): - return iter( - [ - self.ADVANCED_QUERY, - self.BEGIN_DATE, - self.END_DATE, - self.EXPOSURE_TYPES, - self.C42_USERNAME, - self.ACTOR, - self.MD5, - self.SHA256, - self.SOURCE, - self.FILE_NAME, - self.FILE_PATH, - self.PROCESS_OWNER, - self.TAB_URL, - self.INCLUDE_NON_EXPOSURE_EVENTS, - ] - ) diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index 422205e65..6a0e43b02 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -1,22 +1,18 @@ -import json - -from c42eventextractor import ExtractionHandlers from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.filters import * -import code42cli.cmds.securitydata.date_helper as date_helper -from code42cli.cmds.securitydata.enums import ( - ExposureType as ExposureTypeOptions, - IS_INCREMENTAL_KEY, - SearchArguments, +from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions +from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore +from code42cli.cmds.search_shared.extraction import ( + verify_begin_date_requirements, + create_handlers, + exit_if_advanced_query_used_with_other_search_args, + create_time_range_filter, ) -from code42cli.cmds.shared.cursor_store import FileEventCursorStore -from code42cli.compat import str import code42cli.errors as errors from code42cli.logger import get_main_cli_logger - -_TOTAL_EVENTS = 0 +logger = get_main_cli_logger() def extract(sdk, profile, output_logger, args): @@ -31,60 +27,20 @@ def extract(sdk, profile, output_logger, args): send-to: uses a logger that sends logs to a server. args: Command line args used to build up file event query filters. """ - store = _create_cursor_store(args, profile) - filters = _get_filters(args, store) - handlers = _create_event_handlers(output_logger, store) + store = FileEventCursorStore(profile.name) if args.incremental else None + handlers = create_handlers(output_logger, store, event_key=u"fileEvents") extractor = FileEventExtractor(sdk, handlers) - _call_extract(extractor, filters, args.advanced_query) - _handle_result() - - -def _create_cursor_store(args, profile): - if args.incremental: - return FileEventCursorStore(profile.name) - - -def _get_filters(args, cursor_store): - if not _determine_if_advanced_query(args): - _verify_begin_date_requirements(args, cursor_store) - _verify_exposure_types(args.type) - return _create_filters(args) + if args.advanced_query: + exit_if_advanced_query_used_with_other_search_args(args) + extractor.extract_advanced(args.advanced_query) else: - return args.advanced_query - - -def _determine_if_advanced_query(args): - if args.advanced_query is not None: - given_args = vars(args) - for key in given_args: - val = given_args[key] - if not _verify_compatibility_with_advanced_query(key, val): - logger = get_main_cli_logger() - logger.print_and_log_error( - u"You cannot use --advanced-query with additional search args." - ) - exit(1) - return True - return False - - -def _verify_begin_date_requirements(args, cursor_store): - if _begin_date_is_required(args, cursor_store) and not args.begin: - logger = get_main_cli_logger() - logger.print_and_log_error(u"'begin date' is required.\n") - logger.print_bold(u"Try using '-b' or '--begin'. Use `-h` for more info.\n") - exit(1) - - -def _begin_date_is_required(args, cursor_store): - if not args.incremental: - return True - is_required = cursor_store and cursor_store.get_stored_insertion_timestamp() is None - - # Ignore begin date when is incremental mode, it is not required, and it was passed an argument. - if not is_required and args.begin: - args.begin = None - return is_required + verify_begin_date_requirements(args, store) + if args.type: + _verify_exposure_types(args.type) + filters = _create_file_event_filters(args) + extractor.extract(*filters) + if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: + logger.print_info(u"No results found.") def _verify_exposure_types(exposure_types): @@ -93,14 +49,13 @@ def _verify_exposure_types(exposure_types): options = list(ExposureTypeOptions()) for exposure_type in exposure_types: if exposure_type not in options: - logger = get_main_cli_logger() logger.print_and_log_error(u"'{0}' is not a valid exposure type.".format(exposure_type)) exit(1) -def _create_filters(args): +def _create_file_event_filters(args): filters = [] - event_timestamp_filter = _get_event_timestamp_filter(args.begin, args.end) + event_timestamp_filter = create_time_range_filter(EventTimestamp, args.begin, args.end) not event_timestamp_filter or filters.append(event_timestamp_filter) not args.c42_username or filters.append(DeviceUsername.is_in(args.c42_username)) not args.actor or filters.append(Actor.is_in(args.actor)) @@ -115,76 +70,6 @@ def _create_filters(args): return filters -def _get_event_timestamp_filter(begin_date, end_date): - try: - begin_date = begin_date.strip() if begin_date else None - end_date = end_date.strip() if end_date else None - return date_helper.create_event_timestamp_filter(begin_date, end_date) - except date_helper.DateArgumentException as ex: - get_main_cli_logger().print_and_log_error(str(ex)) - exit(1) - - -def _create_event_handlers(output_logger, cursor_store): - handlers = ExtractionHandlers() - - def handle_error(exception): - logger = get_main_cli_logger() - logger.log_error(exception) - errors.ERRORED = True - - handlers.handle_error = handle_error - - if cursor_store: - handlers.record_cursor_position = cursor_store.replace_stored_insertion_timestamp - handlers.get_cursor_position = cursor_store.get_stored_insertion_timestamp - - def handle_response(response): - response_dict = json.loads(response.text) - events = response_dict.get(u"fileEvents") - global _TOTAL_EVENTS - _TOTAL_EVENTS += len(events) - for event in events: - output_logger.info(event) - - handlers.handle_response = handle_response - return handlers - - -def _call_extract(extractor, filters, advanced_query): - if advanced_query: - extractor.extract_advanced(advanced_query) - else: - extractor.extract(*filters) - - -def _verify_compatibility_with_advanced_query(key, val): - if key == SearchArguments.INCLUDE_NON_EXPOSURE_EVENTS and not val: - return True - - if val is not None: - is_other_search_arg = key in SearchArguments() and key != SearchArguments.ADVANCED_QUERY - is_incremental = key == IS_INCREMENTAL_KEY and val - return not is_other_search_arg and not is_incremental - return True - - -def _handle_result(): - # Have to call this explicitly (instead of relying on invoker) because errors are caught in - # `c42eventextractor`. - logger = get_main_cli_logger() - _print_errors_occurred_if_needed(logger) - if not _TOTAL_EVENTS: - logger.print_and_log_info(u"No results found.") - - -def _print_errors_occurred_if_needed(logger): - """If interactive and errors occurred, it will print a message telling the user how to retrieve - error logs.""" - if errors.ERRORED: - logger.print_errors_occurred_message() - - def _try_append_exposure_types_filter(filters, include_non_exposure_events, exposure_types): _exposure_filter = _create_exposure_type_filter(include_non_exposure_events, exposure_types) if _exposure_filter: @@ -193,9 +78,7 @@ def _try_append_exposure_types_filter(filters, include_non_exposure_events, expo def _create_exposure_type_filter(include_non_exposure_events, exposure_types): if include_non_exposure_events and exposure_types: - get_main_cli_logger().print_and_log_error( - u"Cannot use exposure types with `--include-non-exposure`." - ) + logger.print_and_log_error(u"Cannot use exposure types with `--include-non-exposure`.") exit(1) if exposure_types: return ExposureType.is_in(exposure_types) diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index 9c41445b0..f3a4827c6 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -1,7 +1,12 @@ from code42cli.args import ArgConfig -from code42cli.cmds.securitydata import enums, logger_factory +from code42cli.cmds.search_shared import logger_factory, args +from code42cli.cmds.search_shared.enums import ( + FileEventFilterArguments, + ServerProtocol, + ExposureType, +) from code42cli.cmds.securitydata.extraction import extract -from code42cli.cmds.shared.cursor_store import FileEventCursorStore +from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore from code42cli.commands import Command @@ -38,7 +43,7 @@ def load_subcommands(): clear = Command( u"clear-checkpoint", - u"Remove the saved checkpoint from 'incremental' (-i) mode.", + u"Remove the saved file event checkpoint from 'incremental' (-i) mode.", u"{} {}".format(usage_prefix, u"clear-checkpoint "), handler=clear_checkpoint, ) @@ -47,11 +52,11 @@ def load_subcommands(): def clear_checkpoint(sdk, profile): - """Removes the stored checkpoint that keeps track of the last event you got. + """Removes the stored checkpoint that keeps track of the last file event retrieved for the given profile. To use, run `code42 security-data clear-checkpoint`. This affects `incremental` mode by causing it to behave like it has never been run before. """ - FileEventCursorStore(profile.name).replace_stored_insertion_timestamp(None) + FileEventCursorStore(profile.name).replace_stored_cursor_timestamp(None) def print_out(sdk, profile, args): @@ -84,8 +89,8 @@ def _load_send_to_args(arg_collection): u"protocol": ArgConfig( u"-p", u"--protocol", - choices=enums.ServerProtocol(), - default=enums.ServerProtocol.UDP, + choices=ServerProtocol(), + default=ServerProtocol.UDP, help=u"Protocol used to send logs to server.", ), } @@ -95,101 +100,66 @@ def _load_send_to_args(arg_collection): def _load_search_args(arg_collection): - search_args = { - enums.SearchArguments.ADVANCED_QUERY: ArgConfig( - u"--{}".format(enums.SearchArguments.ADVANCED_QUERY.replace(u"_", u"-")), - help=u"A raw JSON file event query. " - u"Useful for when the provided query parameters do not satisfy your requirements." - u"WARNING: Using advanced queries ignores all other query parameters.", - ), - enums.SearchArguments.BEGIN_DATE: ArgConfig( - u"-b", - u"--{}".format(enums.SearchArguments.BEGIN_DATE), - help=u"The beginning of the date range in which to look for events, " - u"can be a date/time in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format " - u"or a short value representing days (30d), hours (24h) or minutes (15m) from current " - u"time.", - ), - enums.SearchArguments.END_DATE: ArgConfig( - u"-e", - u"--{}".format(enums.SearchArguments.END_DATE), - help=u"The end of the date range in which to look for events, " - u"can be a date/time in YYYY-MM-DD (UTC) or YYYY-MM-DD HH:MM:SS (UTC+24-hr time) format " - u"or a short value representing days (30d), hours (24h) or minutes (15m) from current " - u"time.", - ), - enums.SearchArguments.EXPOSURE_TYPES: ArgConfig( + filter_args = { + FileEventFilterArguments.EXPOSURE_TYPES: ArgConfig( u"-t", - u"--{}".format(enums.SearchArguments.EXPOSURE_TYPES), + u"--{}".format(FileEventFilterArguments.EXPOSURE_TYPES), nargs=u"+", help=u"Limits events to those with given exposure types. " - u"Available choices={0}".format(list(enums.ExposureType())), + u"Available choices={0}".format(list(ExposureType())), ), - enums.SearchArguments.C42_USERNAME: ArgConfig( - u"--{}".format(enums.SearchArguments.C42_USERNAME.replace(u"_", u"-")), + FileEventFilterArguments.C42_USERNAME: ArgConfig( + u"--{}".format(FileEventFilterArguments.C42_USERNAME.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to endpoint events for these users.", ), - enums.SearchArguments.ACTOR: ArgConfig( - u"--{}".format(enums.SearchArguments.ACTOR), + FileEventFilterArguments.ACTOR: ArgConfig( + u"--{}".format(FileEventFilterArguments.ACTOR), nargs=u"+", help=u"Limits events to only those enacted by the cloud service user of the person who caused the event.", ), - enums.SearchArguments.MD5: ArgConfig( - u"--{}".format(enums.SearchArguments.MD5), + FileEventFilterArguments.MD5: ArgConfig( + u"--{}".format(FileEventFilterArguments.MD5), nargs=u"+", help=u"Limits events to file events where the file has one of these MD5 hashes.", ), - enums.SearchArguments.SHA256: ArgConfig( - u"--{}".format(enums.SearchArguments.SHA256), + FileEventFilterArguments.SHA256: ArgConfig( + u"--{}".format(FileEventFilterArguments.SHA256), nargs=u"+", action=u"store", help=u"Limits events to file events where the file has one of these SHA256 hashes.", ), - enums.SearchArguments.SOURCE: ArgConfig( - u"--{}".format(enums.SearchArguments.SOURCE), + FileEventFilterArguments.SOURCE: ArgConfig( + u"--{}".format(FileEventFilterArguments.SOURCE), nargs=u"+", help=u"Limits events to only those from one of these sources. Example=Gmail.", ), - enums.SearchArguments.FILE_NAME: ArgConfig( - u"--{}".format(enums.SearchArguments.FILE_NAME.replace(u"_", u"-")), + FileEventFilterArguments.FILE_NAME: ArgConfig( + u"--{}".format(FileEventFilterArguments.FILE_NAME.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to file events where the file has one of these names.", ), - enums.SearchArguments.FILE_PATH: ArgConfig( - u"--{}".format(enums.SearchArguments.FILE_PATH.replace(u"_", u"-")), + FileEventFilterArguments.FILE_PATH: ArgConfig( + u"--{}".format(FileEventFilterArguments.FILE_PATH.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to file events where the file is located at one of these paths.", ), - enums.SearchArguments.PROCESS_OWNER: ArgConfig( - u"--{}".format(enums.SearchArguments.PROCESS_OWNER.replace(u"_", u"-")), + FileEventFilterArguments.PROCESS_OWNER: ArgConfig( + u"--{}".format(FileEventFilterArguments.PROCESS_OWNER.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to exposure events where one of these users " u"owns the process behind the exposure.", ), - enums.SearchArguments.TAB_URL: ArgConfig( - u"--{}".format(enums.SearchArguments.TAB_URL.replace(u"_", u"-")), + FileEventFilterArguments.TAB_URL: ArgConfig( + u"--{}".format(FileEventFilterArguments.TAB_URL.replace(u"_", u"-")), nargs=u"+", help=u"Limits events to be exposure events with one of these destination tab URLs.", ), - enums.SearchArguments.INCLUDE_NON_EXPOSURE_EVENTS: ArgConfig( + FileEventFilterArguments.INCLUDE_NON_EXPOSURE_EVENTS: ArgConfig( u"--include-non-exposure", action=u"store_true", help=u"Get all events including non-exposure events.", ), - u"format": ArgConfig( - u"-f", - u"--format", - choices=enums.OutputFormat(), - default=enums.OutputFormat.JSON, - help=u"The format used for outputting events.", - ), - u"incremental": ArgConfig( - u"-i", - u"--incremental", - action=u"store_true", - help=u"Only get events that were not previously retrieved.", - ), } - + search_args = args.create_search_args(search_for=u"file events", filter_args=filter_args) arg_collection.extend(search_args) diff --git a/src/code42cli/compat.py b/src/code42cli/compat.py index 2d21273f7..52484211f 100644 --- a/src/code42cli/compat.py +++ b/src/code42cli/compat.py @@ -23,6 +23,8 @@ import repr as reprlib import Queue as queue + + range = xrange else: from urllib.parse import urljoin, urlparse @@ -32,3 +34,5 @@ import reprlib import queue + + range = range diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py index af9a222ee..c779b3b8c 100644 --- a/src/code42cli/date_helper.py +++ b/src/code42cli/date_helper.py @@ -19,12 +19,19 @@ def __init__(self, message=_FORMAT_VALUE_ERROR_MESSAGE): super(DateArgumentException, self).__init__(message) +def verify_timestamp_order(min_timestamp, max_timestamp): + if min_timestamp is None or max_timestamp is None: + return + if min_timestamp >= max_timestamp: + raise DateArgumentException(u"Begin date cannot be after end date") + + def parse_min_timestamp(begin_date_str, max_days_back=90): dt = _parse_timestamp(begin_date_str, _round_datetime_to_day_start) boundary_date = _round_datetime_to_day_start(datetime.utcnow() - timedelta(days=max_days_back)) if dt < boundary_date: - raise DateArgumentException(u"'Begin date' must be within 90 days.") + raise DateArgumentException(u"'Begin date' must be within {0} days.".format(max_days_back)) return convert_datetime_to_timestamp(dt) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 6a367b1ab..87bb03c01 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -9,6 +9,7 @@ from code42cli.cmds.detectionlists import high_risk_employee as hre from code42cli.cmds.detectionlists.enums import DetectionLists from code42cli.cmds.securitydata import main as secmain +from code42cli.cmds.alerts import main as alertmain from code42cli.commands import Command from code42cli.invoker import CommandInvoker from code42cli.cmds.alerts.rules.commands import AlertRulesCommands @@ -48,6 +49,11 @@ def _load_top_commands(): u"Tools for getting security related data, such as file events.", subcommand_loader=secmain.load_subcommands, ), + Command( + u"alerts", + u"Tools for getting alert data.", + subcommand_loader=alertmain.load_subcommands, + ), Command( DetectionLists.DEPARTING_EMPLOYEE, detection_lists_description.format(u"departing employee"), diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index acec8903b..5d9556444 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -1,6 +1,6 @@ from code42cli.compat import str import code42cli.password as password -from code42cli.cmds.shared.cursor_store import get_file_event_cursor_store +from code42cli.cmds.search_shared.cursor_store import get_all_cursor_stores_for_profile from code42cli.config import ConfigAccessor, config_accessor, NoConfigProfileError from code42cli.logger import get_main_cli_logger @@ -110,8 +110,9 @@ def delete_profile(profile_name): profile = _get_profile(profile_name) if password.get_stored_password(profile) is not None: password.delete_password(profile) - cursor_store = get_file_event_cursor_store(profile_name) - cursor_store.clean() + cursor_stores = get_all_cursor_stores_for_profile(profile_name) + for store in cursor_stores: + store.clean() config_accessor.delete_profile(profile_name) get_main_cli_logger().print_info(u"Profile '{}' has been deleted.".format(profile_name)) diff --git a/tests/cmds/alerts/test_cursor_store.py b/tests/cmds/alerts/test_cursor_store.py new file mode 100644 index 000000000..f64882d0e --- /dev/null +++ b/tests/cmds/alerts/test_cursor_store.py @@ -0,0 +1,87 @@ +from os import path + +from code42cli import PRODUCT_NAME +from code42cli.cmds.search_shared.cursor_store import BaseCursorStore, AlertCursorStore + + +class TestBaseCursorStore(object): + def test_init_cursor_store_when_not_given_db_file_path_uses_expected_default_checkpoints_path( + self, sqlite_connection + ): + home_dir = path.expanduser("~") + expected_path = path.join(home_dir, ".code42cli/db") + db_table_name = "TEST" + expected_db_file_path = "{0}/file_event_checkpoints.db".format(expected_path) + BaseCursorStore(db_table_name) + sqlite_connection.assert_called_once_with(expected_db_file_path) + + def test_init_cursor_store_when_given_db_file_path_uses_given_path(self, sqlite_connection): + expected_db_file_path = "Hey, look, I'm a file path..." + BaseCursorStore("test", expected_db_file_path) + sqlite_connection.assert_called_once_with(expected_db_file_path) + + +class TestAlertCursorStore(object): + MOCK_TEST_DB_NAME = "test_path.db" + + def test_init_when_called_twice_with_different_profile_names_creates_two_rows( + self, mocker, sqlite_connection + ): + mock = mocker.patch( + "{}.cmds.search_shared.cursor_store.AlertCursorStore._row_exists".format(PRODUCT_NAME) + ) + mock.return_value = False + spy = mocker.spy(AlertCursorStore, "_insert_new_row") + AlertCursorStore("Profile A", self.MOCK_TEST_DB_NAME) + AlertCursorStore("Profile B", self.MOCK_TEST_DB_NAME) + assert spy.call_count == 2 + + def test_get_stored_cursor_timestamp_executes_expected_select_query(self, sqlite_connection): + store = AlertCursorStore("Profile", self.MOCK_TEST_DB_NAME) + store.get_stored_cursor_timestamp() + with store._connection as conn: + expected = "SELECT {0} FROM alert_checkpoints WHERE cursor_id=?".format(u"createdAt") + actual = conn.cursor().execute.call_args[0][0] + assert actual == expected + + def test_get_stored_cursor_timestamp_executes_query_with_expected_primary_key( + self, sqlite_connection + ): + store = AlertCursorStore("Profile", self.MOCK_TEST_DB_NAME) + store.get_stored_cursor_timestamp() + with store._connection as conn: + actual = conn.cursor().execute.call_args[0][1][0] + expected = store._primary_key + assert actual == expected + + def test_replace_stored_cursor_timestamp_executes_expected_update_query( + self, sqlite_connection + ): + store = AlertCursorStore("Profile", self.MOCK_TEST_DB_NAME) + store.replace_stored_cursor_timestamp(123) + with store._connection as conn: + expected = "UPDATE alert_checkpoints SET {0}=? WHERE cursor_id=?".format(u"createdAt") + actual = conn.execute.call_args[0][0] + assert actual == expected + + def test_replace_stored_cursor_timestamp_executes_query_with_expected_primary_key( + self, sqlite_connection + ): + store = AlertCursorStore("Profile", self.MOCK_TEST_DB_NAME) + new_cursor_timestamp = 123 + store.replace_stored_cursor_timestamp(new_cursor_timestamp) + with store._connection as conn: + actual = conn.execute.call_args[0][1][0] + assert actual == new_cursor_timestamp + + def test_clean_executes_query_with_expected_primary_key(self, sqlite_connection): + profile_name = "Profile" + store = AlertCursorStore(profile_name, self.MOCK_TEST_DB_NAME) + store.clean() + with store._connection as conn: + expected_query = "DELETE FROM {0} WHERE {1}=?".format( + store._table_name, store._PRIMARY_KEY_COLUMN_NAME + ) + actual_query, pk = conn.execute.call_args[0] + assert expected_query == actual_query + assert pk == (profile_name,) diff --git a/tests/cmds/alerts/test_extraction.py b/tests/cmds/alerts/test_extraction.py new file mode 100644 index 000000000..d8253a12a --- /dev/null +++ b/tests/cmds/alerts/test_extraction.py @@ -0,0 +1,440 @@ +import logging + +import pytest +from py42.sdk import SDKClient +from py42.sdk.queries.alerts.filters import * + +import code42cli.cmds.alerts.extraction as extraction_module +import code42cli.errors as errors +from code42cli import PRODUCT_NAME +from code42cli.date_helper import DateArgumentException +from tests.cmds.conftest import get_filter_value_from_json +from ...conftest import get_test_date_str, begin_date_str, ErrorTrackerTestHelper + + +@pytest.fixture +def alert_extractor(mocker): + mock = mocker.MagicMock() + mock.extract_advanced = mocker.patch( + "c42eventextractor.extractors.AlertExtractor.extract_advanced" + ) + mock.extract = mocker.patch("c42eventextractor.extractors.AlertExtractor.extract") + return mock + + +@pytest.fixture +def alert_namespace_with_begin(alert_namespace): + alert_namespace.begin = begin_date_str + return alert_namespace + + +@pytest.fixture +def alert_checkpoint(mocker): + return mocker.patch( + "{}.cmds.search_shared.cursor_store.AlertCursorStore.get_stored_cursor_timestamp".format( + PRODUCT_NAME + ) + ) + + +def filter_term_is_in_call_args(extractor, term): + arg_filters = extractor.extract.call_args[0] + for f in arg_filters: + if term in str(f): + return True + return False + + +def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( + sdk, profile, logger, alert_namespace, alert_extractor +): + alert_namespace.advanced_query = "some complex json" + extraction_module.extract(sdk, profile, logger, alert_namespace) + alert_extractor.extract_advanced.assert_called_once_with("some complex json") + assert alert_extractor.extract.call_count == 0 + + +def test_extract_when_is_advanced_query_and_has_begin_date_exits( + sdk, profile, logger, alert_namespace +): + alert_namespace.advanced_query = "some complex json" + alert_namespace.begin = "begin date" + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +def test_extract_when_is_advanced_query_and_has_end_date_exits( + sdk, profile, logger, alert_namespace +): + alert_namespace.advanced_query = "some complex json" + alert_namespace.end = "end date" + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +@pytest.mark.parametrize( + "arg", + [ + "severity", + "actor", + "actor_contains", + "exclude_actor", + "exclude_actor_contains", + "rule_name", + "exclude_rule_name", + "rule_id", + "exclude_rule_id", + "rule_type", + "exclude_rule_type", + ], +) +def test_extract_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( + sdk, profile, logger, alert_namespace, arg +): + alert_namespace.advanced_query = "some complex json" + setattr(alert_namespace, arg, ["test_value"]) + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +@pytest.mark.parametrize("arg", ["state", "description"]) +def test_extract_when_is_advanced_query_and_other_incompatible_single_arg_argument_passed( + sdk, profile, logger, alert_namespace, arg +): + alert_namespace.advanced_query = "some complex json" + setattr(alert_namespace, arg, "test_value") + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +def test_extract_when_is_advanced_query_and_has_incremental_mode_exits( + sdk, profile, logger, file_event_namespace +): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.incremental = True + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, file_event_namespace) + + +def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( + sdk, profile, logger, alert_namespace +): + alert_namespace.advanced_query = "some complex json" + alert_namespace.is_incremental = False + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +def test_extract_when_is_not_advanced_query_uses_only_extract_method( + sdk, profile, logger, alert_extractor, alert_namespace_with_begin +): + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert alert_extractor.extract.call_count == 1 + assert alert_extractor.extract_raw.call_count == 0 + + +def test_extract_when_not_given_begin_or_advanced_causes_exit( + sdk, profile, logger, alert_namespace +): + alert_namespace.begin = None + alert_namespace.advanced_query = None + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +def test_extract_when_given_begin_date_uses_expected_query( + sdk, profile, logger, alert_namespace, alert_extractor +): + alert_namespace.begin = get_test_date_str(days_ago=89) + extraction_module.extract(sdk, profile, logger, alert_namespace) + actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) + expected = "{0}T00:00:00.000Z".format(alert_namespace.begin) + assert actual == expected + + +def test_extract_when_given_begin_date_and_time_uses_expected_query( + sdk, profile, logger, alert_namespace, alert_extractor +): + date = get_test_date_str(days_ago=89) + time = "15:33:02" + alert_namespace.begin = get_test_date_str(days_ago=89) + " " + time + extraction_module.extract(sdk, profile, logger, alert_namespace) + actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) + expected = "{0}T{1}.000Z".format(date, time) + assert actual == expected + + +def test_extract_when_given_begin_date_and_time_without_seconds_uses_expected_query( + sdk, profile, logger, alert_namespace, alert_extractor +): + date = get_test_date_str(days_ago=89) + time = "15:33" + alert_namespace.begin = get_test_date_str(days_ago=89) + " " + time + extraction_module.extract(sdk, profile, logger, alert_namespace) + actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) + expected = "{0}T{1}:00.000Z".format(date, time) + assert actual == expected + + +def test_extract_when_given_end_date_uses_expected_query( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.end = get_test_date_str(days_ago=10) + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) + expected = "{0}T23:59:59.999Z".format(alert_namespace_with_begin.end) + assert actual == expected + + +def test_extract_when_given_end_date_and_time_uses_expected_query( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + date = get_test_date_str(days_ago=10) + time = "12:00:11" + alert_namespace_with_begin.end = date + " " + time + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) + expected = "{0}T{1}.000Z".format(date, time) + assert actual == expected + + +def test_extract_when_given_end_date_and_time_without_seconds_uses_expected_query( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + date = get_test_date_str(days_ago=10) + time = "12:00" + alert_namespace_with_begin.end = date + " " + time + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) + expected = "{0}T{1}:00.000Z".format(date, time) + assert actual == expected + + +def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( + sdk, profile, logger, alert_namespace, alert_extractor +): + end_date = get_test_date_str(days_ago=55) + end_time = "13:44:44" + alert_namespace.begin = get_test_date_str(days_ago=89) + alert_namespace.end = end_date + " " + end_time + extraction_module.extract(sdk, profile, logger, alert_namespace) + + actual_begin_timestamp = get_filter_value_from_json( + alert_extractor.extract.call_args[0][0], filter_index=0 + ) + actual_end_timestamp = get_filter_value_from_json( + alert_extractor.extract.call_args[0][0], filter_index=1 + ) + expected_begin_timestamp = "{0}T00:00:00.000Z".format(alert_namespace.begin) + expected_end_timestamp = "{0}T{1}.000Z".format(end_date, end_time) + + assert actual_begin_timestamp == expected_begin_timestamp + assert actual_end_timestamp == expected_end_timestamp + + +def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( + sdk, profile, logger, alert_namespace +): + alert_namespace.incremental = False + date = get_test_date_str(days_ago=91) + " 12:51:00" + alert_namespace.begin = date + with pytest.raises(DateArgumentException): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +def test_extract_when_end_date_is_before_begin_date_causes_exit( + sdk, profile, logger, alert_namespace +): + alert_namespace.begin = get_test_date_str(days_ago=5) + alert_namespace.end = get_test_date_str(days_ago=6) + with pytest.raises(DateArgumentException): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + sdk, profile, logger, alert_namespace, alert_extractor, alert_checkpoint +): + alert_namespace.begin = "2019-01-01" + alert_namespace.incremental = True + alert_checkpoint.return_value = 22624624 + extraction_module.extract(sdk, profile, logger, alert_namespace) + assert not filter_term_is_in_call_args(alert_extractor, DateObserved._term) + + +def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( + sdk, profile, logger, alert_namespace, alert_extractor, alert_checkpoint +): + alert_namespace.begin = get_test_date_str(days_ago=1) + alert_namespace.incremental = False + alert_checkpoint.return_value = 22624624 + extraction_module.extract(sdk, profile, logger, alert_namespace) + + actual_ts = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) + expected_ts = "{0}T00:00:00.000Z".format(alert_namespace.begin) + assert actual_ts == expected_ts + assert filter_term_is_in_call_args(alert_extractor, DateObserved._term) + + +def test_when_not_given_begin_date_and_is_incremental_but_no_stored_checkpoint_exists_causes_exit( + sdk, profile, logger, alert_namespace, alert_checkpoint +): + alert_namespace.begin = None + alert_namespace.is_incremental = True + alert_checkpoint.return_value = None + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +def test_extract_when_given_actor_is_uses_username_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.actor = ["test.testerson@example.com"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + Actor.is_in(alert_namespace_with_begin.actor) + ) + + +def test_extract_when_given_exclude_actor_uses_actor_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.exclude_actor = ["test.testerson"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + Actor.not_in(alert_namespace_with_begin.exclude_actor) + ) + + +def test_extract_when_given_rule_name_uses_rule_name_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.rule_name = ["departing employee"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + RuleName.is_in(alert_namespace_with_begin.rule_name) + ) + + +def test_extract_when_given_exclude_rule_name_uses_rule_name_not_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.exclude_rule_name = ["departing employee"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + RuleName.not_in(alert_namespace_with_begin.exclude_rule_name) + ) + + +def test_extract_when_given_rule_type_uses_rule_name_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.rule_type = ["departing employee"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + RuleType.is_in(alert_namespace_with_begin.rule_type) + ) + + +def test_extract_when_given_exclude_rule_type_uses_rule_name_not_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.exclude_rule_type = ["departing employee"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + RuleType.not_in(alert_namespace_with_begin.exclude_rule_type) + ) + + +def test_extract_when_given_rule_id_uses_rule_name_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.rule_id = ["departing employee"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + RuleId.is_in(alert_namespace_with_begin.rule_id) + ) + + +def test_extract_when_given_exclude_rule_id_uses_rule_name_not_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.exclude_rule_id = ["departing employee"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + RuleId.not_in(alert_namespace_with_begin.exclude_rule_id) + ) + + +def test_extract_when_given_description_uses_description_filter( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.description = ["catch the bad guys"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + Description.contains(alert_namespace_with_begin.description) + ) + + +def test_extract_when_given_multiple_search_args_uses_expected_filters( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor +): + alert_namespace_with_begin.actor = ["test.testerson@example.com"] + alert_namespace_with_begin.exclude_actor = ["flag.flagerson@code42.com"] + alert_namespace_with_begin.rule_name = ["departing employee"] + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert str(alert_extractor.extract.call_args[0][1]) == str( + Actor.is_in(alert_namespace_with_begin.actor) + ) + assert str(alert_extractor.extract.call_args[0][2]) == str( + Actor.not_in(alert_namespace_with_begin.exclude_actor) + ) + assert str(alert_extractor.extract.call_args[0][3]) == str( + RuleName.is_in(alert_namespace_with_begin.rule_name) + ) + + +def test_extract_when_creating_sdk_throws_causes_exit( + sdk, profile, logger, alert_namespace, mock_42 +): + def side_effect(): + raise Exception() + + mock_42.side_effect = side_effect + with pytest.raises(SystemExit): + extraction_module.extract(sdk, profile, logger, alert_namespace) + + +def test_extract_when_not_errored_and_does_not_log_error_occurred( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor, caplog +): + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + with caplog.at_level(logging.ERROR): + assert "View exceptions that occurred at" not in caplog.text + + +def test_extract_when_not_errored_and_is_interactive_does_not_print_error( + sdk, profile, logger, alert_namespace_with_begin, alert_extractor, cli_logger, mocker +): + errors.ERRORED = False + mocker.patch("code42cli.cmds.securitydata.extraction.logger", cli_logger) + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert cli_logger.print_and_log_error.call_count == 0 + assert cli_logger.log_error.call_count == 0 + errors.ERRORED = False + + +def test_when_sdk_raises_exception_global_variable_gets_set( + mocker, sdk, profile, logger, alert_namespace_with_begin, mock_42 +): + errors.ERRORED = False + mock_sdk = mocker.MagicMock() + + def sdk_side_effect(self, *args): + raise Exception() + + mock_sdk.security.search_file_events.side_effect = sdk_side_effect + mock_42.return_value = mock_sdk + + mocker.patch("c42eventextractor.extractors.BaseExtractor._verify_filter_groups") + with ErrorTrackerTestHelper(): + extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) + assert errors.ERRORED diff --git a/tests/cmds/alerts/test_main.py b/tests/cmds/alerts/test_main.py new file mode 100644 index 000000000..039638458 --- /dev/null +++ b/tests/cmds/alerts/test_main.py @@ -0,0 +1,35 @@ +import pytest + +import code42cli.cmds.alerts.main as main +from code42cli import PRODUCT_NAME + + +@pytest.fixture +def mock_logger_factory(mocker): + return mocker.patch("{}.cmds.alerts.main.logger_factory".format(PRODUCT_NAME)) + + +@pytest.fixture +def mock_extract(mocker): + return mocker.patch("{}.cmds.alerts.main.extract".format(PRODUCT_NAME)) + + +def test_print_out(sdk, profile, alert_namespace, mocker, mock_logger_factory, mock_extract): + logger = mocker.MagicMock() + mock_logger_factory.get_logger_for_stdout.return_value = logger + main.print_out(sdk, profile, alert_namespace) + mock_extract.assert_called_with(sdk, profile, logger, alert_namespace) + + +def test_write_to(sdk, profile, alert_namespace, mocker, mock_logger_factory, mock_extract): + logger = mocker.MagicMock() + mock_logger_factory.get_logger_for_file.return_value = logger + main.write_to(sdk, profile, alert_namespace) + mock_extract.assert_called_with(sdk, profile, logger, alert_namespace) + + +def test_send_to(sdk, profile, alert_namespace, mocker, mock_logger_factory, mock_extract): + logger = mocker.MagicMock() + mock_logger_factory.get_logger_for_server.return_value = logger + main.send_to(sdk, profile, alert_namespace) + mock_extract.assert_called_with(sdk, profile, logger, alert_namespace) diff --git a/tests/cmds/alerts/test_util.py b/tests/cmds/alerts/test_util.py new file mode 100644 index 000000000..f1da19a07 --- /dev/null +++ b/tests/cmds/alerts/test_util.py @@ -0,0 +1,54 @@ +import code42cli.cmds.alerts.util as alert_util + + +ALERT_SUMMARY_LIST = [{"id": i} for i in range(20)] + +ALERT_DETAIL_RESULT = [ + {"alerts": [{"id": 1, "createdAt": "2020-01-17"}, {"id": 11, "createdAt": "2020-01-18"}]}, + {"alerts": [{"id": 2, "createdAt": "2020-01-19"}, {"id": 12, "createdAt": "2020-01-20"}]}, + {"alerts": [{"id": 3, "createdAt": "2020-01-01"}, {"id": 13, "createdAt": "2020-01-02"}]}, + {"alerts": [{"id": 4, "createdAt": "2020-01-03"}, {"id": 14, "createdAt": "2020-01-04"}]}, + {"alerts": [{"id": 5, "createdAt": "2020-01-05"}, {"id": 15, "createdAt": "2020-01-06"}]}, + {"alerts": [{"id": 6, "createdAt": "2020-01-07"}, {"id": 16, "createdAt": "2020-01-08"}]}, + {"alerts": [{"id": 7, "createdAt": "2020-01-09"}, {"id": 17, "createdAt": "2020-01-10"}]}, + {"alerts": [{"id": 8, "createdAt": "2020-01-11"}, {"id": 18, "createdAt": "2020-01-12"}]}, + {"alerts": [{"id": 9, "createdAt": "2020-01-13"}, {"id": 19, "createdAt": "2020-01-14"}]}, + {"alerts": [{"id": 10, "createdAt": "2020-01-15"}, {"id": 20, "createdAt": "2020-01-16"}]}, +] + +SORTED_ALERT_DETAILS = [ + {"id": 12, "createdAt": "2020-01-20"}, + {"id": 2, "createdAt": "2020-01-19"}, + {"id": 11, "createdAt": "2020-01-18"}, + {"id": 1, "createdAt": "2020-01-17"}, + {"id": 20, "createdAt": "2020-01-16"}, + {"id": 10, "createdAt": "2020-01-15"}, + {"id": 19, "createdAt": "2020-01-14"}, + {"id": 9, "createdAt": "2020-01-13"}, + {"id": 18, "createdAt": "2020-01-12"}, + {"id": 8, "createdAt": "2020-01-11"}, + {"id": 17, "createdAt": "2020-01-10"}, + {"id": 7, "createdAt": "2020-01-09"}, + {"id": 16, "createdAt": "2020-01-08"}, + {"id": 6, "createdAt": "2020-01-07"}, + {"id": 15, "createdAt": "2020-01-06"}, + {"id": 5, "createdAt": "2020-01-05"}, + {"id": 14, "createdAt": "2020-01-04"}, + {"id": 4, "createdAt": "2020-01-03"}, + {"id": 13, "createdAt": "2020-01-02"}, + {"id": 3, "createdAt": "2020-01-01"}, +] + + +def test_get_alert_details_batches_results_according_to_batch_size(sdk): + alert_util._BATCH_SIZE = 2 + sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT + results = alert_util.get_alert_details(sdk, ALERT_SUMMARY_LIST) + assert sdk.alerts.get_details.call_count == 10 + + +def test_get_alert_details_sorts_results_by_date(sdk): + alert_util._BATCH_SIZE = 2 + sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT + results = alert_util.get_alert_details(sdk, ALERT_SUMMARY_LIST) + assert results == SORTED_ALERT_DETAILS diff --git a/tests/cmds/securitydata/conftest.py b/tests/cmds/conftest.py similarity index 68% rename from tests/cmds/securitydata/conftest.py rename to tests/cmds/conftest.py index 639e8e4a9..5fd56f611 100644 --- a/tests/cmds/securitydata/conftest.py +++ b/tests/cmds/conftest.py @@ -2,10 +2,33 @@ import pytest +from py42.sdk import SDKClient from code42cli import PRODUCT_NAME -from ...conftest import convert_str_to_date +from code42cli.logger import CliLogger +from tests.conftest import convert_str_to_date -SECURITYDATA_NAMESPACE = "{}.cmds.securitydata".format(PRODUCT_NAME) + +@pytest.fixture +def sdk(mocker): + return mocker.MagicMock(spec=SDKClient) + + +@pytest.fixture() +def mock_42(mocker): + return mocker.patch("py42.sdk.from_local_account") + + +@pytest.fixture +def logger(mocker): + mock = mocker.MagicMock() + mock.print_info = mocker.MagicMock() + return mock + + +@pytest.fixture +def cli_logger(mocker): + mock = mocker.MagicMock(spec=CliLogger) + return mock def get_filter_value_from_json(json, filter_index): diff --git a/tests/cmds/search_shared/__init__.py b/tests/cmds/search_shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cmds/securitydata/test_date_helper.py b/tests/cmds/search_shared/test_date_helper.py similarity index 53% rename from tests/cmds/securitydata/test_date_helper.py rename to tests/cmds/search_shared/test_date_helper.py index 0e1d87df9..4162f81ee 100644 --- a/tests/cmds/securitydata/test_date_helper.py +++ b/tests/cmds/search_shared/test_date_helper.py @@ -1,11 +1,11 @@ import pytest -from code42cli.cmds.securitydata.date_helper import ( - create_event_timestamp_filter, - DateArgumentException, -) -from .conftest import get_filter_value_from_json -from ...conftest import ( +from code42cli.date_helper import DateArgumentException +from code42cli.cmds.search_shared.extraction import create_time_range_filter +from py42.sdk.queries.fileevents.filters import InsertionTimestamp, EventTimestamp +from py42.sdk.queries.alerts.filters import DateObserved +from tests.cmds.conftest import get_filter_value_from_json +from tests.conftest import ( begin_date_str, begin_date_with_time, end_date_str, @@ -13,50 +13,65 @@ get_test_date_str, ) +timestamp_filter_list = [InsertionTimestamp, EventTimestamp, DateObserved] + -def test_create_event_timestamp_filter_when_given_nothing_returns_none(): - ts_range = create_event_timestamp_filter() +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_given_nothing_returns_none(timestamp_filter_class): + ts_range = create_time_range_filter(timestamp_filter_class) assert not ts_range -def test_create_event_timestamp_filter_when_given_nones_returns_none(): - ts_range = create_event_timestamp_filter(None, None) +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_given_nones_returns_none(timestamp_filter_class): + ts_range = create_time_range_filter(timestamp_filter_class, None, None) assert not ts_range -def test_create_event_timestamp_filter_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_str) +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_builds_expected_query(timestamp_filter_class): + ts_range = create_time_range_filter(timestamp_filter_class, begin_date_str) actual = get_filter_value_from_json(ts_range, filter_index=0) expected = "{0}T00:00:00.000Z".format(begin_date_str) assert actual == expected -def test_create_event_timestamp_filter_when_given_begin_with_time_builds_expected_query(): +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_given_begin_with_time_builds_expected_query( + timestamp_filter_class, +): time_str = u"{} {}".format(*begin_date_with_time) - ts_range = create_event_timestamp_filter(time_str) + ts_range = create_time_range_filter(timestamp_filter_class, time_str) actual = get_filter_value_from_json(ts_range, filter_index=0) expected = "{0}T0{1}.000Z".format(*begin_date_with_time) assert actual == expected -def test_create_event_timestamp_filter_when_given_end_builds_expected_query(): - ts_range = create_event_timestamp_filter(begin_date_str, end_date_str) +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_given_end_builds_expected_query(timestamp_filter_class): + ts_range = create_time_range_filter(timestamp_filter_class, begin_date_str, end_date_str) actual = get_filter_value_from_json(ts_range, filter_index=1) expected = "{0}T23:59:59.999Z".format(end_date_str) assert actual == expected -def test_create_event_timestamp_filter_when_given_end_with_time_builds_expected_query(): +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_given_end_with_time_builds_expected_query( + timestamp_filter_class, +): end_date_str = "{} {}".format(*end_date_with_time) - ts_range = create_event_timestamp_filter(begin_date_str, end_date_str) + ts_range = create_time_range_filter(timestamp_filter_class, begin_date_str, end_date_str) actual = get_filter_value_from_json(ts_range, filter_index=1) expected = "{0}T{1}.000Z".format(*end_date_with_time) assert actual == expected -def test_create_event_timestamp_filter_when_given_both_begin_and_end_builds_expected_query(): +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_given_both_begin_and_end_builds_expected_query( + timestamp_filter_class, +): end_date = "{} {}".format(*end_date_with_time) - ts_range = create_event_timestamp_filter(begin_date_str, end_date) + ts_range = create_time_range_filter(timestamp_filter_class, begin_date_str, end_date) actual_begin = get_filter_value_from_json(ts_range, filter_index=0) actual_end = get_filter_value_from_json(ts_range, filter_index=1) expected_begin = "{0}T00:00:00.000Z".format(begin_date_str) @@ -65,10 +80,13 @@ def test_create_event_timestamp_filter_when_given_both_begin_and_end_builds_expe assert actual_end == expected_end -def test_create_event_timestamp_filter_when_given_short_time_args_builds_expected_query(): +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_given_short_time_args_builds_expected_query( + timestamp_filter_class, +): begin_date = "{} 10".format(begin_date_str) end_date = "{} 12:37".format(end_date_str) - ts_range = create_event_timestamp_filter(begin_date, end_date) + ts_range = create_time_range_filter(timestamp_filter_class, begin_date, end_date) actual_begin = get_filter_value_from_json(ts_range, filter_index=0) actual_end = get_filter_value_from_json(ts_range, filter_index=1) expected_begin = "{0}T10:00:00.000Z".format(begin_date_str) @@ -77,23 +95,32 @@ def test_create_event_timestamp_filter_when_given_short_time_args_builds_expecte assert actual_end == expected_end -def test_create_event_timestamp_filter_when_begin_more_than_ninety_days_back_causes_value_error(): +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_begin_more_than_ninety_days_back_causes_value_error( + timestamp_filter_class, +): begin_date_str = get_test_date_str(days_ago=91) with pytest.raises(DateArgumentException): - create_event_timestamp_filter(begin_date_str) + create_time_range_filter(timestamp_filter_class, begin_date_str) -def test_create_event_timestamp_filter_when_end_is_before_begin_causes_value_error(): +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_end_is_before_begin_causes_value_error( + timestamp_filter_class, +): begin_date = get_test_date_str(days_ago=5) end_date = get_test_date_str(days_ago=7) with pytest.raises(DateArgumentException): - create_event_timestamp_filter(begin_date, end_date) + create_time_range_filter(timestamp_filter_class, begin_date, end_date) -def test_create_event_timestamp_filter_when_args_are_magic_days_builds_expected_query(): +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) +def test_create_event_timestamp_filter_when_args_are_magic_days_builds_expected_query( + timestamp_filter_class, +): begin_magic_str = "10d" end_magic_str = "6d" - ts_range = create_event_timestamp_filter(begin_magic_str, end_magic_str) + ts_range = create_time_range_filter(timestamp_filter_class, begin_magic_str, end_magic_str) actual_begin = get_filter_value_from_json(ts_range, filter_index=0) expected_begin = "{}T00:00:00.000Z".format(get_test_date_str(days_ago=10)) actual_end = get_filter_value_from_json(ts_range, filter_index=1) @@ -112,8 +139,9 @@ def test_create_event_timestamp_filter_when_args_are_magic_days_builds_expected_ "10 d", ], ) +@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) def test_create_event_timestamp_filter_when_given_improperly_formatted_arg_raises_value_error( - bad_date_param, + bad_date_param, timestamp_filter_class ): with pytest.raises(DateArgumentException): - create_event_timestamp_filter(bad_date_param) + create_time_range_filter(timestamp_filter_class, bad_date_param) diff --git a/tests/cmds/securitydata/test_logger_factory.py b/tests/cmds/search_shared/test_logger_factory.py similarity index 97% rename from tests/cmds/securitydata/test_logger_factory.py rename to tests/cmds/search_shared/test_logger_factory.py index 09d62e989..44de59269 100644 --- a/tests/cmds/securitydata/test_logger_factory.py +++ b/tests/cmds/search_shared/test_logger_factory.py @@ -7,7 +7,7 @@ FileEventDictToRawJSONFormatter, ) -import code42cli.cmds.securitydata.logger_factory as factory +import code42cli.cmds.search_shared.logger_factory as factory @pytest.fixture @@ -99,7 +99,7 @@ def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(no_prior def test_get_logger_for_server_when_given_json_format_uses_json_formatter( - no_priority_syslog_handler + no_priority_syslog_handler, ): factory.get_logger_for_server("example.com", "TCP", "JSON").handlers = [] factory.get_logger_for_server("example.com", "TCP", "JSON") @@ -108,7 +108,7 @@ def test_get_logger_for_server_when_given_json_format_uses_json_formatter( def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter( - no_priority_syslog_handler + no_priority_syslog_handler, ): factory.get_logger_for_server("example.com", "TCP", "RAW-JSON").handlers = [] factory.get_logger_for_server("example.com", "TCP", "RAW-JSON") diff --git a/tests/cmds/securitydata/test_cursor_store.py b/tests/cmds/securitydata/test_cursor_store.py index 4a4132cf8..9a2c581d9 100644 --- a/tests/cmds/securitydata/test_cursor_store.py +++ b/tests/cmds/securitydata/test_cursor_store.py @@ -1,18 +1,18 @@ from os import path from code42cli import PRODUCT_NAME -from code42cli.cmds.shared.cursor_store import BaseCursorStore, FileEventCursorStore +from code42cli.cmds.search_shared.cursor_store import BaseCursorStore, FileEventCursorStore class TestBaseCursorStore(object): - def test_init_cursor_store_when_not_given_db_file_path_uses_expected_path_with_db_table_name_as_db_file_name( + def test_init_cursor_store_when_not_given_db_file_path_uses_expected_default_checkpoints_path( self, sqlite_connection ): home_dir = path.expanduser("~") expected_path = path.join(home_dir, ".code42cli/db") - expected_db_name = "TEST" - expected_db_file_path = "{0}/{1}.db".format(expected_path, expected_db_name) - BaseCursorStore(expected_db_name) + db_table_name = "TEST" + expected_db_file_path = "{0}/file_event_checkpoints.db".format(expected_path) + BaseCursorStore(db_table_name) sqlite_connection.assert_called_once_with(expected_db_file_path) def test_init_cursor_store_when_given_db_file_path_uses_given_path(self, sqlite_connection): @@ -28,7 +28,9 @@ def test_init_when_called_twice_with_different_profile_names_creates_two_rows( self, mocker, sqlite_connection ): mock = mocker.patch( - "{}.cmds.shared.cursor_store.FileEventCursorStore._row_exists".format(PRODUCT_NAME) + "{}.cmds.search_shared.cursor_store.FileEventCursorStore._row_exists".format( + PRODUCT_NAME + ) ) mock.return_value = False spy = mocker.spy(FileEventCursorStore, "_insert_new_row") @@ -36,9 +38,9 @@ def test_init_when_called_twice_with_different_profile_names_creates_two_rows( FileEventCursorStore("Profile B", self.MOCK_TEST_DB_NAME) assert spy.call_count == 2 - def test_get_stored_insertion_timestamp_executes_expected_select_query(self, sqlite_connection): + def test_get_stored_cursor_timestamp_executes_expected_select_query(self, sqlite_connection): store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.get_stored_insertion_timestamp() + store.get_stored_cursor_timestamp() with store._connection as conn: expected = "SELECT {0} FROM file_event_checkpoints WHERE cursor_id=?".format( u"insertionTimestamp" @@ -46,21 +48,21 @@ def test_get_stored_insertion_timestamp_executes_expected_select_query(self, sql actual = conn.cursor().execute.call_args[0][0] assert actual == expected - def test_get_stored_insertion_timestamp_executes_query_with_expected_primary_key( + def test_get_stored_cursor_timestamp_executes_query_with_expected_primary_key( self, sqlite_connection ): store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.get_stored_insertion_timestamp() + store.get_stored_cursor_timestamp() with store._connection as conn: actual = conn.cursor().execute.call_args[0][1][0] expected = store._primary_key assert actual == expected - def test_replace_stored_insertion_timestamp_executes_expected_update_query( + def test_replace_stored_cursor_timestamp_executes_expected_update_query( self, sqlite_connection ): store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.replace_stored_insertion_timestamp(123) + store.replace_stored_cursor_timestamp(123) with store._connection as conn: expected = "UPDATE file_event_checkpoints SET {0}=? WHERE cursor_id=?".format( u"insertionTimestamp" @@ -68,15 +70,15 @@ def test_replace_stored_insertion_timestamp_executes_expected_update_query( actual = conn.execute.call_args[0][0] assert actual == expected - def test_replace_stored_insertion_timestamp_executes_query_with_expected_primary_key( + def test_replace_stored_cursor_timestamp_executes_query_with_expected_primary_key( self, sqlite_connection ): store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) - new_insertion_timestamp = 123 - store.replace_stored_insertion_timestamp(new_insertion_timestamp) + new_cursor_timestamp = 123 + store.replace_stored_cursor_timestamp(new_cursor_timestamp) with store._connection as conn: actual = conn.execute.call_args[0][1][0] - assert actual == new_insertion_timestamp + assert actual == new_cursor_timestamp def test_clean_executes_query_with_expected_primary_key(self, sqlite_connection): profile_name = "Profile" diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index 9ca99b775..aa064f434 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -1,36 +1,19 @@ import pytest import logging -from py42.sdk import SDKClient from py42.sdk.queries.fileevents.filters import * import code42cli.cmds.securitydata.extraction as extraction_module import code42cli.errors as errors from code42cli import PRODUCT_NAME -from code42cli.cmds.securitydata.enums import ExposureType as ExposureTypeOptions -from .conftest import get_filter_value_from_json +from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions +from tests.cmds.conftest import get_filter_value_from_json +from code42cli.date_helper import DateArgumentException from ...conftest import get_test_date_str, begin_date_str, ErrorTrackerTestHelper @pytest.fixture -def sdk(mocker): - return mocker.MagicMock(spec=SDKClient) - - -@pytest.fixture() -def mock_42(mocker): - return mocker.patch("py42.sdk.from_local_account") - - -@pytest.fixture -def logger(mocker): - mock = mocker.MagicMock() - mock.print_info = mocker.MagicMock() - return mock - - -@pytest.fixture -def extractor(mocker): +def file_event_extractor(mocker): mock = mocker.MagicMock() mock.extract_advanced = mocker.patch( "c42eventextractor.extractors.FileEventExtractor.extract_advanced" @@ -40,15 +23,15 @@ def extractor(mocker): @pytest.fixture -def namespace_with_begin(namespace): - namespace.begin = begin_date_str - return namespace +def file_event_namespace_with_begin(file_event_namespace): + file_event_namespace.begin = begin_date_str + return file_event_namespace @pytest.fixture -def checkpoint(mocker): +def file_event_checkpoint(mocker): return mocker.patch( - "{}.cmds.shared.cursor_store.FileEventCursorStore.get_stored_insertion_timestamp".format( + "{}.cmds.search_shared.cursor_store.FileEventCursorStore.get_stored_cursor_timestamp".format( PRODUCT_NAME ) ) @@ -63,213 +46,197 @@ def filter_term_is_in_call_args(extractor, term): def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( - sdk, profile, logger, namespace, extractor + sdk, profile, logger, file_event_namespace, file_event_extractor ): - namespace.advanced_query = "some complex json" - extraction_module.extract(sdk, profile, logger, namespace) - extractor.extract_advanced.assert_called_once_with("some complex json") - assert extractor.extract.call_count == 0 - - -def test_extract_when_is_advanced_query_and_has_begin_date_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.begin = "begin date" - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + file_event_namespace.advanced_query = "some complex json" + extraction_module.extract(sdk, profile, logger, file_event_namespace) + file_event_extractor.extract_advanced.assert_called_once_with("some complex json") + assert file_event_extractor.extract.call_count == 0 -def test_extract_when_is_advanced_query_and_has_end_date_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.end = "end date" - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_exposure_types_exits( - sdk, profile, logger, namespace +def test_extract_when_is_advanced_query_and_has_begin_date_exits( + sdk, profile, logger, file_event_namespace ): - namespace.advanced_query = "some complex json" - namespace.type = [ExposureTypeOptions.SHARED_TO_DOMAIN] + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.begin = "begin date" with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + extraction_module.extract(sdk, profile, logger, file_event_namespace) -def test_extract_when_is_advanced_query_and_has_username_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.c42_username = ["Someone"] - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_actor_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.actor = ["Someone"] - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_md5_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.md5 = ["098f6bcd4621d373cade4e832627b4f6"] - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_sha256_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.sha256 = ["9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"] - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_source_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.source = ["Gmail"] - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_file_name_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.file_name = ["test.out"] - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_file_path_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.file_path = ["path/to/file"] +def test_extract_when_is_advanced_query_and_has_end_date_exits( + sdk, profile, logger, file_event_namespace +): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.end = "end date" with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + extraction_module.extract(sdk, profile, logger, file_event_namespace) -def test_extract_when_is_advanced_query_and_has_process_owner_exits( - sdk, profile, logger, namespace +def test_extract_when_is_advanced_query_and_has_exposure_types_exits( + sdk, profile, logger, file_event_namespace ): - namespace.advanced_query = "some complex json" - namespace.process_owner = ["someone"] + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.type = [ExposureTypeOptions.SHARED_TO_DOMAIN] with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_is_advanced_query_and_has_tab_url_exits(sdk, profile, logger, namespace): - namespace.advanced_query = "some complex json" - namespace.tab_url = ["https://www.example.com"] + extraction_module.extract(sdk, profile, logger, file_event_namespace) + + +@pytest.mark.parametrize( + "arg", + [ + "c42_username", + "actor", + "md5", + "sha256", + "source", + "file_name", + "file_path", + "process_owner", + "tab_url", + ], +) +def test_extract_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( + sdk, profile, logger, file_event_namespace, arg +): + file_event_namespace.advanced_query = "some complex json" + setattr(file_event_namespace, arg, ["test_value"]) with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_is_advanced_query_and_has_incremental_mode_exits( - sdk, profile, logger, namespace + sdk, profile, logger, file_event_namespace ): - namespace.advanced_query = "some complex json" - namespace.incremental = True + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.incremental = True with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_is_advanced_query_and_has_include_non_exposure_exits( - sdk, profile, logger, namespace + sdk, profile, logger, file_event_namespace ): - namespace.advanced_query = "some complex json" - namespace.include_non_exposure = True + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.include_non_exposure = True with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_is_advanced_query_and_include_non_exposure_is_false_does_not_exit( - sdk, profile, logger, namespace + sdk, profile, logger, file_event_namespace ): - namespace.include_non_exposure = False - namespace.advanced_query = "some complex json" - extraction_module.extract(sdk, profile, logger, namespace) + file_event_namespace.include_non_exposure = False + file_event_namespace.advanced_query = "some complex json" + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( - sdk, profile, logger, namespace + sdk, profile, logger, file_event_namespace ): - namespace.advanced_query = "some complex json" - namespace.is_incremental = False - extraction_module.extract(sdk, profile, logger, namespace) + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.is_incremental = False + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_is_not_advanced_query_uses_only_extract_method( - sdk, profile, logger, extractor, namespace_with_begin + sdk, profile, logger, file_event_extractor, file_event_namespace_with_begin ): - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert extractor.extract.call_count == 1 - assert extractor.extract_raw.call_count == 0 + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert file_event_extractor.extract.call_count == 1 + assert file_event_extractor.extract_raw.call_count == 0 def test_extract_when_not_given_begin_or_advanced_causes_exit( - sdk, profile, logger, extractor, namespace + sdk, profile, logger, file_event_namespace ): - namespace.begin = None - namespace.advanced_query = None + file_event_namespace.begin = None + file_event_namespace.advanced_query = None with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_given_begin_date_uses_expected_query( - sdk, profile, logger, namespace, extractor + sdk, profile, logger, file_event_namespace, file_event_extractor ): - namespace.begin = get_test_date_str(days_ago=89) - extraction_module.extract(sdk, profile, logger, namespace) - actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T00:00:00.000Z".format(namespace.begin) + file_event_namespace.begin = get_test_date_str(days_ago=89) + extraction_module.extract(sdk, profile, logger, file_event_namespace) + actual = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][0], filter_index=0 + ) + expected = "{0}T00:00:00.000Z".format(file_event_namespace.begin) assert actual == expected def test_extract_when_given_begin_date_and_time_uses_expected_query( - sdk, profile, logger, namespace, extractor + sdk, profile, logger, file_event_namespace, file_event_extractor ): date = get_test_date_str(days_ago=89) time = "15:33:02" - namespace.begin = get_test_date_str(days_ago=89) + " " + time - extraction_module.extract(sdk, profile, logger, namespace) - actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) + file_event_namespace.begin = get_test_date_str(days_ago=89) + " " + time + extraction_module.extract(sdk, profile, logger, file_event_namespace) + actual = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][0], filter_index=0 + ) expected = "{0}T{1}.000Z".format(date, time) assert actual == expected def test_extract_when_given_end_date_uses_expected_query( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.end = get_test_date_str(days_ago=10) - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T23:59:59.999Z".format(namespace_with_begin.end) + file_event_namespace_with_begin.end = get_test_date_str(days_ago=10) + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + actual = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][0], filter_index=1 + ) + expected = "{0}T23:59:59.999Z".format(file_event_namespace_with_begin.end) assert actual == expected def test_extract_when_given_end_date_and_time_uses_expected_query( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): date = get_test_date_str(days_ago=10) time = "12:00:11" - namespace_with_begin.end = date + " " + time - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - actual = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=1) + file_event_namespace_with_begin.end = date + " " + time + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + actual = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][0], filter_index=1 + ) expected = "{0}T{1}.000Z".format(date, time) assert actual == expected +def test_extract_when_given_end_date_and_time_without_seconds_uses_expected_query( + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor +): + date = get_test_date_str(days_ago=10) + time = "12:00" + file_event_namespace_with_begin.end = date + " " + time + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + actual = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][0], filter_index=1 + ) + expected = "{0}T{1}:00.000Z".format(date, time) + assert actual == expected + + def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( - sdk, profile, logger, namespace, extractor + sdk, profile, logger, file_event_namespace, file_event_extractor ): end_date = get_test_date_str(days_ago=55) end_time = "13:44:44" - namespace.begin = get_test_date_str(days_ago=89) - namespace.end = end_date + " " + end_time - extraction_module.extract(sdk, profile, logger, namespace) + file_event_namespace.begin = get_test_date_str(days_ago=89) + file_event_namespace.end = end_date + " " + end_time + extraction_module.extract(sdk, profile, logger, file_event_namespace) actual_begin_timestamp = get_filter_value_from_json( - extractor.extract.call_args[0][0], filter_index=0 + file_event_extractor.extract.call_args[0][0], filter_index=0 ) actual_end_timestamp = get_filter_value_from_json( - extractor.extract.call_args[0][0], filter_index=1 + file_event_extractor.extract.call_args[0][0], filter_index=1 ) - expected_begin_timestamp = "{0}T00:00:00.000Z".format(namespace.begin) + expected_begin_timestamp = "{0}T00:00:00.000Z".format(file_event_namespace.begin) expected_end_timestamp = "{0}T{1}.000Z".format(end_date, end_time) assert actual_begin_timestamp == expected_begin_timestamp @@ -277,236 +244,252 @@ def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( - sdk, profile, logger, namespace, extractor + sdk, profile, logger, file_event_namespace ): - namespace.incremental = False + file_event_namespace.incremental = False date = get_test_date_str(days_ago=91) + " 12:51:00" - namespace.begin = date - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + file_event_namespace.begin = date + with pytest.raises(DateArgumentException): + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_end_date_is_before_begin_date_causes_exit( - sdk, profile, logger, namespace, extractor + sdk, profile, logger, file_event_namespace ): - namespace.begin = get_test_date_str(days_ago=5) - namespace.end = get_test_date_str(days_ago=6) - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + file_event_namespace.begin = get_test_date_str(days_ago=5) + file_event_namespace.end = get_test_date_str(days_ago=6) + with pytest.raises(DateArgumentException): + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - sdk, profile, logger, namespace, extractor, checkpoint + sdk, profile, logger, file_event_namespace, file_event_extractor, file_event_checkpoint ): - namespace.begin = "2019-01-01" - namespace.incremental = True - checkpoint.return_value = 22624624 - extraction_module.extract(sdk, profile, logger, namespace) - assert not filter_term_is_in_call_args(extractor, EventTimestamp._term) + file_event_namespace.begin = "2019-01-01" + file_event_namespace.incremental = True + file_event_checkpoint.return_value = 22624624 + extraction_module.extract(sdk, profile, logger, file_event_namespace) + assert not filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) -def test_when_given_begin_date_and_not_incremental_mode_and_cursor_exists_uses_begin_date( - sdk, profile, logger, namespace, extractor +def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( + sdk, profile, logger, file_event_namespace, file_event_extractor, file_event_checkpoint ): - namespace.begin = get_test_date_str(days_ago=1) - namespace.incremental = False - checkpoint.return_value = 22624624 - extraction_module.extract(sdk, profile, logger, namespace) + file_event_namespace.begin = get_test_date_str(days_ago=1) + file_event_namespace.incremental = False + file_event_checkpoint.return_value = 22624624 + extraction_module.extract(sdk, profile, logger, file_event_namespace) - actual_ts = get_filter_value_from_json(extractor.extract.call_args[0][0], filter_index=0) - expected_ts = "{0}T00:00:00.000Z".format(namespace.begin) + actual_ts = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][0], filter_index=0 + ) + expected_ts = "{0}T00:00:00.000Z".format(file_event_namespace.begin) assert actual_ts == expected_ts - assert filter_term_is_in_call_args(extractor, EventTimestamp._term) + assert filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) def test_when_not_given_begin_date_and_is_incremental_but_no_stored_checkpoint_exists_causes_exit( - sdk, profile, logger, namespace, extractor + sdk, profile, logger, file_event_namespace, file_event_checkpoint ): - namespace.begin = None - namespace.is_incremental = True - checkpoint.return_value = None + file_event_namespace.begin = None + file_event_namespace.is_incremental = True + file_event_checkpoint.return_value = None with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_given_invalid_exposure_type_causes_exit( - sdk, profile, logger, namespace, extractor + sdk, profile, logger, file_event_namespace ): - namespace.type = [ + file_event_namespace.type = [ ExposureTypeOptions.APPLICATION_READ, "SomethingElseThatIsNotSupported", ExposureTypeOptions.IS_PUBLIC, ] with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_given_username_uses_username_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.c42_username = ["test.testerson@example.com"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - DeviceUsername.is_in(namespace_with_begin.c42_username) + file_event_namespace_with_begin.c42_username = ["test.testerson@example.com"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + DeviceUsername.is_in(file_event_namespace_with_begin.c42_username) ) def test_extract_when_given_actor_uses_actor_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.actor = ["test.testerson"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(Actor.is_in(namespace_with_begin.actor)) + file_event_namespace_with_begin.actor = ["test.testerson"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + Actor.is_in(file_event_namespace_with_begin.actor) + ) def test_extract_when_given_md5_uses_md5_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.md5 = ["098f6bcd4621d373cade4e832627b4f6"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(MD5.is_in(namespace_with_begin.md5)) + file_event_namespace_with_begin.md5 = ["098f6bcd4621d373cade4e832627b4f6"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + MD5.is_in(file_event_namespace_with_begin.md5) + ) def test_extract_when_given_sha256_uses_sha256_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.sha256 = [ + file_event_namespace_with_begin.sha256 = [ "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" ] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(SHA256.is_in(namespace_with_begin.sha256)) + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + SHA256.is_in(file_event_namespace_with_begin.sha256) + ) def test_extract_when_given_source_uses_source_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.source = ["Gmail", "Yahoo"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(Source.is_in(namespace_with_begin.source)) + file_event_namespace_with_begin.source = ["Gmail", "Yahoo"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + Source.is_in(file_event_namespace_with_begin.source) + ) def test_extract_when_given_file_name_uses_file_name_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.file_name = ["file.txt", "txt.file"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - FileName.is_in(namespace_with_begin.file_name) + file_event_namespace_with_begin.file_name = ["file.txt", "txt.file"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + FileName.is_in(file_event_namespace_with_begin.file_name) ) def test_extract_when_given_file_path_uses_file_path_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.file_path = ["/path/to/file.txt", "path2"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - FilePath.is_in(namespace_with_begin.file_path) + file_event_namespace_with_begin.file_path = ["/path/to/file.txt", "path2"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + FilePath.is_in(file_event_namespace_with_begin.file_path) ) def test_extract_when_given_process_owner_uses_process_owner_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.process_owner = ["test.testerson", "another"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - ProcessOwner.is_in(namespace_with_begin.process_owner) + file_event_namespace_with_begin.process_owner = ["test.testerson", "another"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + ProcessOwner.is_in(file_event_namespace_with_begin.process_owner) ) def test_extract_when_given_tab_url_uses_process_tab_url_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.tab_url = ["https://www.example.com"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(TabURL.is_in(namespace_with_begin.tab_url)) + file_event_namespace_with_begin.tab_url = ["https://www.example.com"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + TabURL.is_in(file_event_namespace_with_begin.tab_url) + ) def test_extract_when_given_exposure_types_uses_exposure_type_is_in_filter( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.type = ["ApplicationRead", "RemovableMedia", "CloudStorage"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - ExposureType.is_in(namespace_with_begin.type) + file_event_namespace_with_begin.type = ["ApplicationRead", "RemovableMedia", "CloudStorage"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + ExposureType.is_in(file_event_namespace_with_begin.type) ) def test_extract_when_given_include_non_exposure_does_not_include_exposure_type_exists( - mocker, sdk, profile, logger, namespace_with_begin, extractor + mocker, sdk, profile, logger, file_event_namespace_with_begin ): - namespace_with_begin.include_non_exposure = True + file_event_namespace_with_begin.include_non_exposure = True ExposureType.exists = mocker.MagicMock() - extraction_module.extract(sdk, profile, logger, namespace_with_begin) + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) assert not ExposureType.exists.call_count def test_extract_when_not_given_include_non_exposure_includes_exposure_type_exists( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor ): - namespace_with_begin.include_non_exposure = False - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str(ExposureType.exists()) + file_event_namespace_with_begin.include_non_exposure = False + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str(ExposureType.exists()) def test_extract_when_given_multiple_search_args_uses_expected_filters( - sdk, profile, logger, namespace_with_begin, extractor -): - namespace_with_begin.file_path = ["/path/to/file.txt"] - namespace_with_begin.process_owner = ["test.testerson", "flag.flagerson"] - namespace_with_begin.tab_url = ["https://www.example.com"] - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert str(extractor.extract.call_args[0][1]) == str( - FilePath.is_in(namespace_with_begin.file_path) + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor +): + file_event_namespace_with_begin.file_path = ["/path/to/file.txt"] + file_event_namespace_with_begin.process_owner = ["test.testerson", "flag.flagerson"] + file_event_namespace_with_begin.tab_url = ["https://www.example.com"] + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert str(file_event_extractor.extract.call_args[0][1]) == str( + FilePath.is_in(file_event_namespace_with_begin.file_path) ) - assert str(extractor.extract.call_args[0][2]) == str( - ProcessOwner.is_in(namespace_with_begin.process_owner) + assert str(file_event_extractor.extract.call_args[0][2]) == str( + ProcessOwner.is_in(file_event_namespace_with_begin.process_owner) + ) + assert str(file_event_extractor.extract.call_args[0][3]) == str( + TabURL.is_in(file_event_namespace_with_begin.tab_url) ) - assert str(extractor.extract.call_args[0][3]) == str(TabURL.is_in(namespace_with_begin.tab_url)) def test_extract_when_given_include_non_exposure_and_exposure_types_causes_exit( - sdk, profile, logger, namespace_with_begin, extractor + sdk, profile, logger, file_event_namespace_with_begin ): - namespace_with_begin.type = ["ApplicationRead", "RemovableMedia", "CloudStorage"] - namespace_with_begin.include_non_exposure = True + file_event_namespace_with_begin.type = ["ApplicationRead", "RemovableMedia", "CloudStorage"] + file_event_namespace_with_begin.include_non_exposure = True with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace_with_begin) + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) def test_extract_when_creating_sdk_throws_causes_exit( - sdk, profile, logger, extractor, namespace, mock_42 + sdk, profile, logger, file_event_namespace, mock_42 ): def side_effect(): raise Exception() mock_42.side_effect = side_effect with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, namespace) - - -def test_extract_when_errored_logs_error_occurred( - sdk, profile, logger, namespace_with_begin, extractor, caplog -): - with ErrorTrackerTestHelper(): - with caplog.at_level(logging.ERROR): - extraction_module.extract(sdk, profile, logger, namespace_with_begin) - assert "ERROR" in caplog.text - assert "View exceptions that occurred at" in caplog.text + extraction_module.extract(sdk, profile, logger, file_event_namespace) def test_extract_when_not_errored_and_does_not_log_error_occurred( - sdk, profile, logger, namespace_with_begin, extractor, caplog + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor, caplog ): - extraction_module.extract(sdk, profile, logger, namespace_with_begin) + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) with caplog.at_level(logging.ERROR): assert "View exceptions that occurred at" not in caplog.text -def test_when_handle_event_raises_exception_global_variable_gets_set( - mocker, sdk, extractor, profile, logger, namespace_with_begin, mock_42 +def test_extract_when_not_errored_and_is_interactive_does_not_print_error( + sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor, cli_logger, mocker +): + errors.ERRORED = False + mocker.patch("code42cli.cmds.securitydata.extraction.logger", cli_logger) + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) + assert cli_logger.print_and_log_error.call_count == 0 + assert cli_logger.log_error.call_count == 0 + errors.ERRORED = False + + +def test_when_sdk_raises_exception_global_variable_gets_set( + mocker, sdk, profile, logger, file_event_namespace_with_begin, mock_42 ): + errors.ERRORED = False mock_sdk = mocker.MagicMock() def sdk_side_effect(self, *args): @@ -517,5 +500,5 @@ def sdk_side_effect(self, *args): mocker.patch("c42eventextractor.extractors.BaseExtractor._verify_filter_groups") with ErrorTrackerTestHelper(): - extraction_module.extract(sdk, profile, logger, namespace_with_begin) + extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) assert errors.ERRORED diff --git a/tests/cmds/securitydata/test_main.py b/tests/cmds/securitydata/test_main.py index 4d05dfb30..df5b5f46c 100644 --- a/tests/cmds/securitydata/test_main.py +++ b/tests/cmds/securitydata/test_main.py @@ -14,22 +14,22 @@ def mock_extract(mocker): return mocker.patch("{}.cmds.securitydata.main.extract".format(PRODUCT_NAME)) -def test_print_out(sdk, profile, namespace, mocker, mock_logger_factory, mock_extract): +def test_print_out(sdk, profile, file_event_namespace, mocker, mock_logger_factory, mock_extract): logger = mocker.MagicMock() mock_logger_factory.get_logger_for_stdout.return_value = logger - main.print_out(sdk, profile, namespace) - mock_extract.assert_called_with(sdk, profile, logger, namespace) + main.print_out(sdk, profile, file_event_namespace) + mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace) -def test_write_to(sdk, profile, namespace, mocker, mock_logger_factory, mock_extract): +def test_write_to(sdk, profile, file_event_namespace, mocker, mock_logger_factory, mock_extract): logger = mocker.MagicMock() mock_logger_factory.get_logger_for_file.return_value = logger - main.write_to(sdk, profile, namespace) - mock_extract.assert_called_with(sdk, profile, logger, namespace) + main.write_to(sdk, profile, file_event_namespace) + mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace) -def test_send_to(sdk, profile, namespace, mocker, mock_logger_factory, mock_extract): +def test_send_to(sdk, profile, file_event_namespace, mocker, mock_logger_factory, mock_extract): logger = mocker.MagicMock() mock_logger_factory.get_logger_for_server.return_value = logger - main.send_to(sdk, profile, namespace) - mock_extract.assert_called_with(sdk, profile, logger, namespace) + main.send_to(sdk, profile, file_event_namespace) + mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace) diff --git a/tests/conftest.py b/tests/conftest.py index 7c30e8cda..4c63061da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -from argparse import Namespace from datetime import datetime, timedelta import pytest @@ -6,33 +5,71 @@ from code42cli.config import ConfigAccessor from code42cli.profile import Code42Profile +from code42cli.commands import DictObject import code42cli.errors as error_tracker @pytest.fixture -def namespace(mocker): - mock = mocker.MagicMock(spec=Namespace) - mock.incremental = None - mock.advanced_query = None - mock.begin = None - mock.end = None - mock.type = None - mock.c42_username = None - mock.actor = None - mock.md5 = None - mock.sha256 = None - mock.source = None - mock.file_name = None - mock.file_path = None - mock.process_owner = None - mock.tab_url = None - mock.include_non_exposure = None - mock.format = None - mock.output_file = None - mock.server = None - mock.protocol = None - return mock +def file_event_namespace(): + args = DictObject( + dict( + sdk=mock_42, + profile=create_mock_profile(), + incremental=None, + advanced_query=None, + begin=None, + end=None, + type=None, + c42_username=None, + actor=None, + md5=None, + sha256=None, + source=None, + file_name=None, + file_path=None, + process_owner=None, + tab_url=None, + include_non_exposure=None, + format=None, + output_file=None, + server=None, + protocol=None, + ) + ) + return args + + +@pytest.fixture +def alert_namespace(): + args = DictObject( + dict( + sdk=mock_42, + profile=create_mock_profile(), + incremental=None, + advanced_query=None, + begin=None, + end=None, + severity=None, + state=None, + actor=None, + actor_contains=None, + exclude_actor=None, + exclude_actor_contains=None, + rule_name=None, + exclude_rule_name=None, + rule_id=None, + exclude_rule_id=None, + rule_type=None, + exclude_rule_type=None, + description=None, + format=None, + output_file=None, + server=None, + protocol=None, + ) + ) + return args def create_profile_values_dict(authority=None, username=None, ignore_ssl=False): diff --git a/tests/test_profile.py b/tests/test_profile.py index 2862dfa84..38a6344c1 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -3,7 +3,7 @@ import code42cli.profile as cliprofile from code42cli import PRODUCT_NAME -from code42cli.cmds.shared.cursor_store import FileEventCursorStore +from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore, AlertCursorStore from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection, create_mock_profile @@ -204,12 +204,14 @@ def test_delete_profile_deletes_password_if_exists( password_deleter.assert_called_once_with(profile) -def test_delete_profile_clears_checkpoint(config_accessor, mocker): +def test_delete_profile_clears_checkpoints(config_accessor, mocker): profile = create_mock_profile("deleteme") mock_get_profile = mocker.patch("code42cli.profile._get_profile") mock_get_profile.return_value = profile - store = mocker.MagicMock(spec=FileEventCursorStore) - mock_get_cursor_store = mocker.patch("code42cli.profile.get_file_event_cursor_store") - mock_get_cursor_store.return_value = store + event_store = mocker.MagicMock(spec=FileEventCursorStore) + alert_store = mocker.MagicMock(spec=AlertCursorStore) + mock_get_cursor_store = mocker.patch("code42cli.profile.get_all_cursor_stores_for_profile") + mock_get_cursor_store.return_value = [event_store, alert_store] cliprofile.delete_profile("deleteme") - assert store.clean.call_count == 1 + assert event_store.clean.call_count == 1 + assert alert_store.clean.call_count == 1 From 55327535a7fa18fb3b78dae299252019de23233e Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Thu, 14 May 2020 08:47:35 -0500 Subject: [PATCH 050/349] prep for prerelease (#59) --- src/code42cli/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 43a1e95ba..5de1738a2 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.5.3" +__version__ = "0.6.0b1" From 163110eb1aa81a505f2d601e36c5930565eab77d Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 15 May 2020 08:30:07 -0500 Subject: [PATCH 051/349] INTEG-1041 Path stuff (#61) --- CHANGELOG.md | 1 + src/code42cli/cmds/search_shared/cursor_store.py | 4 +++- src/code42cli/config.py | 3 ++- src/code42cli/parser.py | 5 +++-- tests/test_logger.py | 5 +++-- tests/test_util.py | 5 ++++- 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cddbbac46..faae5fdc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Fixed - Fixed bug in bulk commands where value-less fields in csv files were treated as empty strings instead of None. +- Fixed anomaly where the path to the error log on Windows contained mixed slashes. ### 0.5.3 - 2020-05-04 diff --git a/src/code42cli/cmds/search_shared/cursor_store.py b/src/code42cli/cmds/search_shared/cursor_store.py index d46708a98..b0504d9d4 100644 --- a/src/code42cli/cmds/search_shared/cursor_store.py +++ b/src/code42cli/cmds/search_shared/cursor_store.py @@ -1,6 +1,7 @@ from __future__ import with_statement import sqlite3 +import os from code42cli.util import get_user_project_path @@ -12,7 +13,8 @@ def __init__(self, db_table_name, db_file_path=None): self._table_name = db_table_name if db_file_path is None: db_path = get_user_project_path(u"db") - db_file_path = u"{0}/file_event_checkpoints.db".format(db_path) + db_file = u"file_event_checkpoints.db" + db_file_path = os.path.join(db_path, db_file) self._connection = sqlite3.connect(db_file_path) if self._is_empty(): diff --git a/src/code42cli/config.py b/src/code42cli/config.py index 7348a45e8..476685cad 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -27,7 +27,8 @@ class ConfigAccessor(object): def __init__(self, parser): self.parser = parser - self.path = u"{}config.cfg".format(util.get_user_project_path()) + file_name = u"config.cfg" + self.path = os.path.join(util.get_user_project_path(), file_name) if not os.path.exists(self.path): self._create_internal_section() self._save() diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py index 9c2b6f8cc..ef509870a 100644 --- a/src/code42cli/parser.py +++ b/src/code42cli/parser.py @@ -23,6 +23,7 @@ class ArgumentParserError(Exception): class CommandParser(argparse.ArgumentParser): def __init__(self, **kwargs): + # noinspection PyTypeChecker super(CommandParser, self).__init__(formatter_class=RawDescriptionHelpFormatter, **kwargs) def prepare_command(self, command, path_parts): @@ -40,7 +41,7 @@ def prepare_cli_help(self, top_command): def error(self, message): # overrides the behavior of when an error occurs when - # arguments cant be successfully parsed. CommandInvoker catches this. + # arguments can't be successfully parsed. CommandInvoker catches this. raise ArgumentParserError(message) def _load_argparse_config(self, command, command_parser): @@ -99,7 +100,7 @@ def _get_group_help(command): output.append(BANNER) output.extend([u" \nAvailable commands in <{}>:".format(name), descriptions]) - return "\n".join(output) + return u"\n".join(output) def _build_group_command_descriptions(command): diff --git a/tests/test_logger.py b/tests/test_logger.py index 4768a1b70..290a7cfe7 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,4 +1,5 @@ import logging +import os from logging.handlers import RotatingFileHandler from requests import Request @@ -36,8 +37,8 @@ def test_logger_has_handlers_when_logger_does_not_have_handlers_returns_false(): def test_get_view_exceptions_location_message_returns_expected_message(): actual = get_view_exceptions_location_message() - path = get_user_project_path() - expected = u"View exceptions that occurred at {}log/code42_errors.log.".format(path) + path = os.path.join(get_user_project_path("log"), "code42_errors.log") + expected = u"View exceptions that occurred at {}.".format(path) assert actual == expected diff --git a/tests/test_util.py b/tests/test_util.py index 88a0f67d8..8515d90f8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -7,9 +7,12 @@ TEST_HEADER = {u"key1": u"Column 1", u"key2": u"Column 10", u"key3": u"Column 100"} +_NAMESPACE = "{}.util".format(PRODUCT_NAME) + + @pytest.fixture def mock_input(mocker): - return mocker.patch("{}.util.get_input".format(PRODUCT_NAME)) + return mocker.patch("{}.get_input".format(_NAMESPACE)) def test_get_url_parts_when_given_host_and_port_returns_expected_parts(): From a2871f029c3cc0cbc1bd77737ec6b777f7f65864 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 15 May 2020 11:46:50 -0500 Subject: [PATCH 052/349] Prevent logging during tests (#63) --- src/code42cli/cmds/search_shared/cursor_store.py | 5 +---- src/code42cli/cmds/search_shared/enums.py | 8 ++------ src/code42cli/logger.py | 2 +- tests/conftest.py | 5 +++++ tests/test_logger.py | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/code42cli/cmds/search_shared/cursor_store.py b/src/code42cli/cmds/search_shared/cursor_store.py index b0504d9d4..95500e98a 100644 --- a/src/code42cli/cmds/search_shared/cursor_store.py +++ b/src/code42cli/cmds/search_shared/cursor_store.py @@ -130,7 +130,4 @@ def get_alert_cursor_store(profile_name): def get_all_cursor_stores_for_profile(profile_name): - return [ - FileEventCursorStore(profile_name), - AlertCursorStore(profile_name), - ] + return [FileEventCursorStore(profile_name), AlertCursorStore(profile_name)] diff --git a/src/code42cli/cmds/search_shared/enums.py b/src/code42cli/cmds/search_shared/enums.py index a21cfee92..f09c64fbd 100644 --- a/src/code42cli/cmds/search_shared/enums.py +++ b/src/code42cli/cmds/search_shared/enums.py @@ -84,11 +84,7 @@ def __len__(self): return len(self._as_list()) def _as_list(self): - return [ - self.ENDPOINT_EXFILTRATION, - self.CLOUD_SHARE_PERMISSIONS, - self.FILE_TYPE_MISMATCH, - ] + return [self.ENDPOINT_EXFILTRATION, self.CLOUD_SHARE_PERMISSIONS, self.FILE_TYPE_MISMATCH] class ServerProtocol(object): @@ -108,7 +104,7 @@ class SearchArguments(object): END_DATE = u"end" def __iter__(self): - return iter([self.ADVANCED_QUERY, self.BEGIN_DATE, self.END_DATE,]) + return iter([self.ADVANCED_QUERY, self.BEGIN_DATE, self.END_DATE]) class FileEventFilterArguments(SearchArguments): diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 3b6c0c953..f000fc5a0 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -40,7 +40,7 @@ def _get_error_log_path(): def _create_error_file_handler(): log_path = _get_error_log_path() - return RotatingFileHandler(log_path, maxBytes=250000000, encoding=u"utf-8") + return RotatingFileHandler(log_path, maxBytes=250000000, encoding=u"utf-8", delay=True) def add_handler_to_logger(logger, handler, formatter): diff --git a/tests/conftest.py b/tests/conftest.py index 4c63061da..d88bd23de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,11 @@ import code42cli.errors as error_tracker +@pytest.fixture(autouse=True) +def io_prevention(monkeypatch): + monkeypatch.setattr("logging.FileHandler._open", lambda *args, **kwargs: None) + + @pytest.fixture def file_event_namespace(): args = DictObject( diff --git a/tests/test_logger.py b/tests/test_logger.py index 290a7cfe7..a5809e0d8 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,8 +1,8 @@ import logging import os from logging.handlers import RotatingFileHandler -from requests import Request +from requests import Request from code42cli.logger import ( add_handler_to_logger, logger_has_handlers, From 2c5144ce63e0597a44a6d2f6e9bb306051a5b109 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Fri, 15 May 2020 12:07:13 -0500 Subject: [PATCH 053/349] Chore/adjustments (#65) --- CHANGELOG.md | 13 +++++++++++-- README.md | 10 +++++++--- src/code42cli/cmds/alerts/rules/commands.py | 4 +++- src/code42cli/cmds/alerts/rules/user_rule.py | 2 +- src/code42cli/cmds/search_shared/args.py | 4 ++-- src/code42cli/cmds/search_shared/cursor_store.py | 2 ++ src/code42cli/main.py | 10 +++++----- src/code42cli/util.py | 4 ++-- tests/test_main.py | 5 +++++ 9 files changed, 38 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faae5fdc6..02bb97668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added -- Ability to search/poll for alerts with checkpointing and sending to console, a file, or a server in json format. +- `code42 alerts`: + - Ability to search/poll for alerts with checkpointing using one of the following commands: + - `print` to output to stdout. + - `write-to` to output to a file. + - `send-to` to output to server via UDP or TCP. + - `code42 alert-rules` commands: - `add-user` with parameters `--rule-id` and `--username`. - `remove-user` that takes a rule ID and optionally `--username`. @@ -24,11 +29,15 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `cmd`: with options `add` and `remove`. - `path` - `remove`: that takes a csv file with rule IDs and usernames. + - Success messages for `profile delete` and `profile update`. + - Additional information in the error log file: - The full command path for the command that errored. - User-facing error messages you see during adhoc sessions. + - A custom error in the error log when you try adding unknown risk tags to user. + - A custom error in the error log when you try adding a user to a detection list who is already added. ### Fixed @@ -46,7 +55,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Fixed -- Issue that prevented bulk csv loading. +- Issue that prevented bulk csv loading. ## 0.5.1 - 2020-04-27 diff --git a/README.md b/README.md index 0d0f3808e..297bb1cd8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Use the `code42` command to interact with your Code42 environment. * `code42 security-data` is a CLI tool for extracting AED events. - Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a + Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a checkpoint (provided you do not change your query). * `code42 high-risk-employee` is a collection of tools for managing the high risk employee detection list. Similarly, there is `code42 departing-employee`. @@ -56,13 +56,16 @@ To see all your profiles, do: code42 profile list ``` -## Security Data +## Security Data and Alerts + +Using the CLI, you can query for security events and alerts and send them to three possible destination types: -Using the CLI, you can query for events and send them to three possible destination types: * stdout * A file * A server, such as SysLog +The following examples pertain to security events, but can also be used for alerts by replacing `security-data` with `alerts`: + To print events to stdout, do: ```bash @@ -110,6 +113,7 @@ code42 security-data print -b 2020-02-02 -f CEF ``` The available formats are CEF, JSON, and RAW-JSON. +Currently, CEF format is only supported for security events. To write events to a file, do: diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py index 0ecc573a4..6f9715a89 100644 --- a/src/code42cli/cmds/alerts/rules/commands.py +++ b/src/code42cli/cmds/alerts/rules/commands.py @@ -22,8 +22,10 @@ def _customize_add_arguments(argument_collection): def _customize_remove_arguments(argument_collection): rule_id = argument_collection.arg_configs[u"rule_id"] rule_id.set_help(u"Observer ID of the rule to be updated.") + rule_id.set_required(True) username = argument_collection.arg_configs[u"username"] username.set_help(u"The username of the user to remove from the alert rule.") + username.set_required(True) def _customize_list_arguments(argument_collection): @@ -113,7 +115,7 @@ def load_subcommands(): remove = Command( u"remove-user", u"Update alert rule criteria to remove a user and all their aliases.", - u"{} remove-user --username ".format(usage_prefix), + u"{} remove-user --rule-id --username ".format(usage_prefix), handler=remove_user, arg_customizer=_customize_remove_arguments, ) diff --git a/src/code42cli/cmds/alerts/rules/user_rule.py b/src/code42cli/cmds/alerts/rules/user_rule.py index faeffe329..58974470d 100644 --- a/src/code42cli/cmds/alerts/rules/user_rule.py +++ b/src/code42cli/cmds/alerts/rules/user_rule.py @@ -22,7 +22,7 @@ def add_user(sdk, profile, rule_id=None, username=None): sdk.alerts.rules.add_user(rule_id, user_id) -def remove_user(sdk, profile, rule_id, username=None): +def remove_user(sdk, profile, rule_id=None, username=None): if username: user_id = get_user_id(sdk, username) sdk.alerts.rules.remove_user(rule_id, user_id) diff --git a/src/code42cli/cmds/search_shared/args.py b/src/code42cli/cmds/search_shared/args.py index 4859fd108..df1c96f8e 100644 --- a/src/code42cli/cmds/search_shared/args.py +++ b/src/code42cli/cmds/search_shared/args.py @@ -17,11 +17,11 @@ def create_search_args(search_for, filter_args): u"-b", u"--{}".format(SearchArguments.BEGIN_DATE), metavar=u"DATE", - help=u"The beginning of the date range in which to look for {1}, " + help=u"The beginning of the date range in which to look for {0}, " u"can be a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format " u"where the 'time' portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') " u"or a short value representing days (30d), hours (24h) or minutes (15m) from current " - u"time.".format(u"beginning", search_for), + u"time.".format(search_for), ), SearchArguments.END_DATE: ArgConfig( u"-e", diff --git a/src/code42cli/cmds/search_shared/cursor_store.py b/src/code42cli/cmds/search_shared/cursor_store.py index 95500e98a..bbdfecf28 100644 --- a/src/code42cli/cmds/search_shared/cursor_store.py +++ b/src/code42cli/cmds/search_shared/cursor_store.py @@ -8,6 +8,8 @@ class BaseCursorStore(object): _PRIMARY_KEY_COLUMN_NAME = u"cursor_id" + _timestamp_column_name = u"OVERRIDE" + _primary_key = u"OVERRIDE" def __init__(self, db_table_name, db_file_path=None): self._table_name = db_table_name diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 87bb03c01..f64cd2194 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -54,6 +54,11 @@ def _load_top_commands(): u"Tools for getting alert data.", subcommand_loader=alertmain.load_subcommands, ), + Command( + u"alert-rules", + u"Manage alert rules", + subcommand_loader=AlertRulesCommands.load_subcommands, + ), Command( DetectionLists.DEPARTING_EMPLOYEE, detection_lists_description.format(u"departing employee"), @@ -64,11 +69,6 @@ def _load_top_commands(): detection_lists_description.format(u"high risk employee"), subcommand_loader=hre.load_subcommands, ), - Command( - u"alert-rules", - u"Manage alert rules", - subcommand_loader=AlertRulesCommands.load_subcommands, - ), ] diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 74b691cd3..30655972e 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -2,7 +2,7 @@ import sys from os import makedirs, path -from code42cli.compat import open +from code42cli.compat import open, str _PADDING_SIZE = 3 @@ -86,5 +86,5 @@ def format_to_table(rows, column_size): """ for row in rows: for key in row.keys(): - print(repr(row[key]).ljust(column_size[key] + _PADDING_SIZE), end=u" ") + print(str(row[key]).ljust(column_size[key] + _PADDING_SIZE), end=u" ") print(u"") diff --git a/tests/test_main.py b/tests/test_main.py index a03d74c30..b23f66a78 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -17,6 +17,11 @@ def test_securitydata_commands_load(capsys, mocker): _execute_test(capsys, u"print") +def test_alerts_commands_load(capsys, mocker): + mocker.patch("sys.argv", [u"code42", u"alerts", u"print", u"-h"]) + _execute_test(capsys, u"print") + + def test_profile_commands_load(capsys, mocker): mocker.patch("sys.argv", [u"code42", u"profile", u"show", u"-h"]) _execute_test(capsys, u"show") From e4c0fa8df252971fff75a4548014884be9b3b16a Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Mon, 18 May 2020 07:59:16 -0500 Subject: [PATCH 054/349] correct help text (#67) Co-authored-by: Kiran Chaudhary --- src/code42cli/cmds/alerts/rules/commands.py | 2 +- src/code42cli/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py index 6f9715a89..c4150053c 100644 --- a/src/code42cli/cmds/alerts/rules/commands.py +++ b/src/code42cli/cmds/alerts/rules/commands.py @@ -36,7 +36,7 @@ def _customize_list_arguments(argument_collection): def _customize_bulk_arguments(argument_collection): file_name = argument_collection.arg_configs[u"file_name"] file_name.set_help( - u"The path to the csv file with columns 'rule_id,user_id' " + u"The path to the csv file with columns 'rule_id,username' " u"for bulk adding users to the alert rule." ) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index f64cd2194..b185f8a40 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -56,7 +56,7 @@ def _load_top_commands(): ), Command( u"alert-rules", - u"Manage alert rules", + u"Manage alert rules.", subcommand_loader=AlertRulesCommands.load_subcommands, ), Command( From 83948139dac90ef13f28456c1e88237aed0a2f31 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 18 May 2020 09:31:42 -0700 Subject: [PATCH 055/349] Fix path (#69) --- src/code42cli/logger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index f000fc5a0..27da13da0 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -1,4 +1,4 @@ -import logging, sys, traceback +import os, logging, sys, traceback from logging.handlers import RotatingFileHandler from threading import Lock import copy @@ -35,7 +35,7 @@ def _get_standard_formatter(): def _get_error_log_path(): log_path = get_user_project_path(u"log") - return u"{}/{}".format(log_path, ERROR_LOG_FILE_NAME) + return os.path.join(log_path, ERROR_LOG_FILE_NAME) def _create_error_file_handler(): From f92947f9f320b5126a6179986a3d2b738db4f4d9 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Mon, 18 May 2020 15:03:19 -0500 Subject: [PATCH 056/349] Feature/required args (#66) --- CHANGELOG.md | 8 ++ src/code42cli/args.py | 50 ++++++------- src/code42cli/cmds/alerts/rules/commands.py | 8 +- src/code42cli/cmds/alerts/rules/user_rule.py | 6 +- .../cmds/detectionlists/high_risk_employee.py | 15 ++-- src/code42cli/cmds/profile.py | 15 ++-- src/code42cli/parser.py | 7 +- tests/cmds/alerts/rules/test_user_rule.py | 16 ++-- tests/conftest.py | 14 ++++ tests/test_commands.py | 74 ++++++++++++++++--- tests/test_parser.py | 2 +- 11 files changed, 147 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02bb97668..c1be3099e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Changed + +- `code42 profile create` now uses required `--name`, `--server` and `--username` flags instead of positional arguments. + +- `code42 high-risk-employee add-risk-tags` now uses required `--username` and `--tag` flags instead of positional arguments. + +- `code42 high-risk-employee remove-risk-tags` now uses required `--username` and `--tag` flags instead of positional arguments. + ### Added - `code42 alerts`: diff --git a/src/code42cli/args.py b/src/code42cli/args.py index b1e1139fd..3a6b86554 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -38,7 +38,7 @@ def add_short_option_name(self, short_name): def as_multi_val_param(self, nargs=u"+"): self._settings[u"nargs"] = nargs - def set_required(self, required=False): + def set_required(self, required): self._settings[u"required"] = required @@ -61,20 +61,22 @@ def get_auto_arg_configs(handler): """Looks at the parameter names of `handler` and builds an `ArgConfigCollection` containing `argparse` parameters based on them.""" arg_configs = ArgConfigCollection() + excluded_args = [SDK_ARG_NAME, u"profile", u"args", u"kwargs", u"self"] if callable(handler): - # get the number of positional and keyword args argspec = inspect.getargspec(handler) - num_args = len(argspec.args) - num_kw_args = len(argspec.defaults) if argspec.defaults else 0 - - for arg_position, key in enumerate(argspec.args): - # do not create cli parameters for arguments named "sdk", "args", or "kwargs" - if not key in [SDK_ARG_NAME, u"args", u"kwargs", u"self"]: - arg_config = _create_auto_args_config( - arg_position, key, argspec, num_args, num_kw_args - ) - _set_smart_defaults(arg_config) - arg_configs.append(key, arg_config) + filtered_argspec = { + key: position for position, key in enumerate(argspec.args) if key not in excluded_args + } + num_optional_args = len(argspec.defaults) if argspec.defaults else 0 + num_positional_args = len(argspec.args) - num_optional_args + num_required_cli_args = len(filtered_argspec) - num_optional_args + + for key in filtered_argspec: + arg_config = _create_auto_args_config( + key, filtered_argspec[key], num_positional_args, num_required_cli_args, argspec + ) + _set_smart_defaults(arg_config) + arg_configs.append(key, arg_config) if SDK_ARG_NAME in argspec.args: _build_sdk_arg_configs(arg_configs) @@ -82,30 +84,28 @@ def get_auto_arg_configs(handler): return arg_configs -def _create_auto_args_config(arg_position, key, argspec, num_args, num_kw_args): +def _create_auto_args_config(key, position, num_positional_args, num_required_cli_args, argspec): default = None + required = None param_name = key.replace(u"_", u"-") - difference = num_args - num_kw_args - last_positional_arg_idx = difference - 1 + last_positional_arg_idx = num_positional_args - 1 + option_names = [u"--{}".format(param_name)] # positional arguments will come first, so if the arg position # is greater than the index of the last positional arg, it's a kwarg. - if arg_position > last_positional_arg_idx: + if position > last_positional_arg_idx: # this is a keyword arg, treat it as an optional cli arg. - default_value = argspec.defaults[arg_position - difference] - option_names = [u"--{}".format(param_name)] + default_value = argspec.defaults[position - num_positional_args] default = default_value - else: + elif num_required_cli_args > 1: # this is a positional arg, treat it as a required cli arg. + required = True + else: option_names = [param_name] - return ArgConfig(*option_names, default=default) + return ArgConfig(*option_names, default=default, required=required) def _set_smart_defaults(arg_config): default = arg_config.settings.get(u"default") - # make a parameter allow lists as input if its default value is a list, - # e.g. --my-param one two three four - nargs = u"+" if type(default) == list else None - arg_config.settings[u"nargs"] = nargs # make the param not require a value (e.g. --enable) if the default value of # the param is a bool. if type(default) == bool: diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py index c4150053c..a7b469cde 100644 --- a/src/code42cli/cmds/alerts/rules/commands.py +++ b/src/code42cli/cmds/alerts/rules/commands.py @@ -6,26 +6,22 @@ get_rules, add_bulk_users, remove_bulk_users, - show_rules, + show_rule, ) def _customize_add_arguments(argument_collection): rule_id = argument_collection.arg_configs[u"rule_id"] rule_id.set_help(u"Observer ID of the rule to be updated. Required.") - rule_id.set_required(True) username = argument_collection.arg_configs[u"username"] username.set_help(u"The username of the user to add to the alert rule. Required.") - username.set_required(True) def _customize_remove_arguments(argument_collection): rule_id = argument_collection.arg_configs[u"rule_id"] rule_id.set_help(u"Observer ID of the rule to be updated.") - rule_id.set_required(True) username = argument_collection.arg_configs[u"username"] username.set_help(u"The username of the user to remove from the alert rule.") - username.set_required(True) def _customize_list_arguments(argument_collection): @@ -131,7 +127,7 @@ def load_subcommands(): u"show", u"Fetch configured alert-rules against the rule ID.", u"{} show ".format(usage_prefix), - handler=show_rules, + handler=show_rule, arg_customizer=_customize_list_arguments, ) diff --git a/src/code42cli/cmds/alerts/rules/user_rule.py b/src/code42cli/cmds/alerts/rules/user_rule.py index 58974470d..600a12516 100644 --- a/src/code42cli/cmds/alerts/rules/user_rule.py +++ b/src/code42cli/cmds/alerts/rules/user_rule.py @@ -17,12 +17,12 @@ } -def add_user(sdk, profile, rule_id=None, username=None): +def add_user(sdk, profile, rule_id, username): user_id = get_user_id(sdk, username) sdk.alerts.rules.add_user(rule_id, user_id) -def remove_user(sdk, profile, rule_id=None, username=None): +def remove_user(sdk, profile, rule_id, username): if username: user_id = get_user_id(sdk, username) sdk.alerts.rules.remove_user(rule_id, user_id) @@ -58,7 +58,7 @@ def remove_bulk_users(sdk, profile, file_name): ) -def show_rules(sdk, profile, rule_id): +def show_rule(sdk, profile, rule_id): selected_rule = _get_rules_metadata(sdk, rule_id) rule_detail = None if len(selected_rule): diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index 43edacc53..5d70a5284 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -24,12 +24,14 @@ def load_subcommands(): Command( u"add-risk-tags", u"Associates risk tags with a user.", + u"code42 high-risk-employee add-risk-tags --username --tag ", handler=add_risk_tags, arg_customizer=_load_risk_tag_mgmt_descriptions, ), Command( u"remove-risk-tags", u"Disassociates risk tags from a user.", + u"code42 high-risk-employee remove-risk-tags --username --tag ", handler=remove_risk_tags, arg_customizer=_load_risk_tag_mgmt_descriptions, ), @@ -44,14 +46,14 @@ def _create_handlers(): ) -def add_risk_tags(sdk, profile, username, risk_tag): - risk_tag = _handle_list_args(risk_tag) +def add_risk_tags(sdk, profile, username, tag): + risk_tag = _handle_list_args(tag) user_id = get_user_id(sdk, username) try_add_risk_tags(sdk, user_id, risk_tag) -def remove_risk_tags(sdk, profile, username, risk_tag): - risk_tag = _handle_list_args(risk_tag) +def remove_risk_tags(sdk, profile, username, tag): + risk_tag = _handle_list_args(tag) user_id = get_user_id(sdk, username) try_remove_risk_tags(sdk, user_id, risk_tag) @@ -92,7 +94,10 @@ def remove_high_risk_employee(sdk, profile, username): def _load_risk_tag_description(argument_collection): - risk_tag = argument_collection.arg_configs[DetectionListUserKeys.RISK_TAG] + risk_tag = ( + argument_collection.arg_configs.get(DetectionListUserKeys.RISK_TAG) + or argument_collection.arg_configs[u"tag"] + ) risk_tag.as_multi_val_param() tags = u", ".join(list(RiskTags())) risk_tag.set_help( diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 3e82ef722..d3d061830 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -47,7 +47,10 @@ def load_subcommands(): create = Command( u"create", u"Create profile settings. The first profile created will be the default.", - u"{} {}".format(usage_prefix, u"create "), + u"{} {}".format( + usage_prefix, + u"create --name --server --username ", + ), handler=create_profile, arg_customizer=_load_profile_create_descriptions, ) @@ -90,10 +93,10 @@ def show_profile(name=None): logger.print_info(u"") -def create_profile(profile, server, username, disable_ssl_errors=False): - cliprofile.create_profile(profile, server, username, disable_ssl_errors) - _prompt_for_allow_password_set(profile) - get_main_cli_logger().print_info(u"Successfully created profile '{}'.".format(profile)) +def create_profile(name, server, username, disable_ssl_errors=False): + cliprofile.create_profile(name, server, username, disable_ssl_errors) + _prompt_for_allow_password_set(name) + get_main_cli_logger().print_info(u"Successfully created profile '{}'.".format(name)) def update_profile(name=None, server=None, username=None, disable_ssl_errors=None): @@ -172,7 +175,7 @@ def _load_optional_profile_description(argument_collection): def _load_profile_create_descriptions(argument_collection): - profile = argument_collection.arg_configs[PROFILE_ARG_NAME] + profile = argument_collection.arg_configs[u"name"] profile.set_help(PROFILE_HELP) _load_profile_settings_descriptions(argument_collection) diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py index ef509870a..5cd61d214 100644 --- a/src/code42cli/parser.py +++ b/src/code42cli/parser.py @@ -46,8 +46,9 @@ def error(self, message): def _load_argparse_config(self, command, command_parser): arg_configs = command.get_arg_configs() + required_group = command_parser.add_argument_group(u"required arguments") for arg in arg_configs: - _add_argument(command_parser, arg_configs[arg].settings) + _add_argument(command_parser, arg_configs[arg].settings, required_group) def _get_parser(self, command, path_parts): usage = command.usage or SUPPRESS @@ -84,10 +85,12 @@ def _get_parent_subparser(path_parts, part, subparsers): return parent_subparser -def _add_argument(parser, arg_settings): +def _add_argument(parser, arg_settings, required_group): # register the settings of an ArgConfig object to an argparse parser options_list = arg_settings.pop(u"options_list") arg_settings = {key: arg_settings[key] for key in arg_settings if arg_settings[key] is not None} + if arg_settings.get(u"required"): + parser = required_group parser.add_argument(*options_list, **arg_settings) diff --git a/tests/cmds/alerts/rules/test_user_rule.py b/tests/cmds/alerts/rules/test_user_rule.py index ea777d546..77534adc6 100644 --- a/tests/cmds/alerts/rules/test_user_rule.py +++ b/tests/cmds/alerts/rules/test_user_rule.py @@ -1,6 +1,6 @@ import pytest -from code42cli.cmds.alerts.rules.user_rule import add_user, remove_user, get_rules, show_rules +from code42cli.cmds.alerts.rules.user_rule import add_user, remove_user, get_rules, show_rule TEST_RULE_ID = u"rule-id" TEST_USER_ID = u"test-user-id" @@ -30,8 +30,6 @@ def test_remove_user_removes_user_list_from_alert_rules(alert_rules_sdk, profile alert_rules_sdk.users.get_by_username.return_value = {u"users": [{u"userUid": TEST_USER_ID}]} remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) alert_rules_sdk.alerts.rules.remove_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) - remove_user(alert_rules_sdk, profile, TEST_RULE_ID) - alert_rules_sdk.alerts.rules.remove_all_users.assert_called_once_with(TEST_RULE_ID) def test_get_rules_gets_alert_rules(alert_rules_sdk, profile): @@ -39,19 +37,19 @@ def test_get_rules_gets_alert_rules(alert_rules_sdk, profile): assert alert_rules_sdk.alerts.rules.get_all.call_count == 1 -def test_show_rules_calls_correct_rule_property(alert_rules_sdk, profile): +def test_show_rule_calls_correct_rule_property(alert_rules_sdk, profile): alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_EXFILTRATION - show_rules(alert_rules_sdk, profile, TEST_RULE_ID) + show_rule(alert_rules_sdk, profile, TEST_RULE_ID) alert_rules_sdk.alerts.rules.exfiltration.get.assert_called_once_with(TEST_RULE_ID) -def test_show_rules_calls_correct_rule_property_cloud_share(alert_rules_sdk, profile): +def test_show_rule_calls_correct_rule_property_cloud_share(alert_rules_sdk, profile): alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_CLOUD_SHARE - show_rules(alert_rules_sdk, profile, TEST_RULE_ID) + show_rule(alert_rules_sdk, profile, TEST_RULE_ID) alert_rules_sdk.alerts.rules.cloudshare.get.assert_called_once_with(TEST_RULE_ID) -def test_show_rules_calls_correct_rule_property_file_type_mismatch(alert_rules_sdk, profile): +def test_show_rule_calls_correct_rule_property_file_type_mismatch(alert_rules_sdk, profile): alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH - show_rules(alert_rules_sdk, profile, TEST_RULE_ID) + show_rule(alert_rules_sdk, profile, TEST_RULE_ID) alert_rules_sdk.alerts.rules.filetypemismatch.get.assert_called_once_with(TEST_RULE_ID) diff --git a/tests/conftest.py b/tests/conftest.py index d88bd23de..7d5de7bc9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,6 +131,14 @@ def func_keyword_args(one=None, two=None, three=None, default="testdefault", nar pass +def func_single_positional_arg(one): + pass + + +def func_single_positional_arg_many_optional_args(one, two=None, three=None, four=None): + pass + + def func_positional_args(one, two, three): pass @@ -143,6 +151,12 @@ def func_with_sdk(sdk, one, two, three=None, four=None): pass +def func_single_positional_arg_with_sdk_and_profile( + sdk, profile, one, two=None, three=None, four=None +): + pass + + def func_with_args(args): pass diff --git a/tests/test_commands.py b/tests/test_commands.py index 6423075d1..5f39f8cbf 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -11,6 +11,9 @@ func_positional_args, func_with_args, func_with_sdk, + func_single_positional_arg_with_sdk_and_profile, + func_single_positional_arg, + func_single_positional_arg_many_optional_args, ) subcommand1 = Command("sub1", "sub1 desc", "sub1 usage") @@ -87,31 +90,41 @@ def test_get_arg_configs_when_keyword_args_has_defaults_set(self): coll = command.get_arg_configs() assert coll["default"].settings["default"] == "testdefault" - def test_get_arg_configs_when_keyword_args_with_list_defaults_has_nargs_set(self): - command = Command("test", "test desc", "test usage", func_keyword_args) + def test_get_arg_configs_when_positional_args_returns_expected_collection(self): + command = Command("test", "test desc", "test usage", func_positional_args) coll = command.get_arg_configs() - assert coll["nargstest"].settings["nargs"] == "+" + assert "--one" in coll["one"].settings["options_list"] + assert "--two" in coll["two"].settings["options_list"] + assert "--three" in coll["three"].settings["options_list"] - def test_get_arg_configs_when_positional_args_returns_expected_collection(self): + def test_get_arg_configs_when_positional_args_returns_all_required_args(self): command = Command("test", "test desc", "test usage", func_positional_args) coll = command.get_arg_configs() - assert "one" in coll["one"].settings["options_list"] - assert "two" in coll["two"].settings["options_list"] - assert "three" in coll["three"].settings["options_list"] + assert coll["one"].settings["required"] == True + assert coll["two"].settings["required"] == True + assert coll["three"].settings["required"] == True def test_get_arg_configs_when_mixed_args_returns_expected_collection(self): command = Command("test", "test desc", "test usage", func_mixed_args) coll = command.get_arg_configs() - assert "one" in coll["one"].settings["options_list"] - assert "two" in coll["two"].settings["options_list"] + assert "--one" in coll["one"].settings["options_list"] + assert "--two" in coll["two"].settings["options_list"] assert "--three" in coll["three"].settings["options_list"] assert "--four" in coll["four"].settings["options_list"] + def test_get_arg_configs_when_mixed_args_returns_positional_args_required(self): + command = Command("test", "test desc", "test usage", func_mixed_args) + coll = command.get_arg_configs() + assert coll["one"].settings["required"] == True + assert coll["two"].settings["required"] == True + assert not coll["three"].settings["required"] + assert not coll["four"].settings["required"] + def test_get_arg_configs_when_handler_with_sdk_includes_profile_and_debug(self): command = Command("test", "test desc", "test usage", func_with_sdk) coll = command.get_arg_configs() - assert "one" in coll["one"].settings["options_list"] - assert "two" in coll["two"].settings["options_list"] + assert "--one" in coll["one"].settings["options_list"] + assert "--two" in coll["two"].settings["options_list"] assert "--three" in coll["three"].settings["options_list"] assert "--four" in coll["four"].settings["options_list"] assert "--profile" in coll[PROFILE_ARG_NAME].settings["options_list"] @@ -123,6 +136,45 @@ def test_get_arg_configs_when_handler_with_args_excludes_args(self): coll = command.get_arg_configs() assert not coll.get("args") + def test_get_arg_configs_when_handler_has_single_positional_arg_and_sdk_and_profile_returns_expected_collection( + self + ): + command = Command( + "test", "test desc", "test usage", func_single_positional_arg_with_sdk_and_profile + ) + coll = command.get_arg_configs() + assert "one" in coll["one"].settings["options_list"] + + def test_get_arg_configs_when_handler_has_single_positional_arg_returns_expected_collection( + self + ): + command = Command("test", "test desc", "test usage", func_single_positional_arg) + coll = command.get_arg_configs() + assert "one" in coll["one"].settings["options_list"] + + def test_get_arg_configs_when_handler_has_single_positional_arg_and_many_optional_args_returns_expected_collection( + self + ): + command = Command( + "test", "test desc", "test usage", func_single_positional_arg_many_optional_args + ) + coll = command.get_arg_configs() + assert "one" in coll["one"].settings["options_list"] + assert "--two" in coll["two"].settings["options_list"] + assert "--three" in coll["three"].settings["options_list"] + assert "--four" in coll["four"].settings["options_list"] + + def test_get_arg_configs_when_handler_has_single_positional_arg_and_many_optional_args_optional_args_are_not_required( + self + ): + command = Command( + "test", "test desc", "test usage", func_single_positional_arg_many_optional_args + ) + coll = command.get_arg_configs() + assert not coll["two"].settings["required"] + assert not coll["three"].settings["required"] + assert not coll["four"].settings["required"] + def test_call_when_keyword_args_passes_expected_values(self, mocker): def test_handler(one=None, two=None, three=None): if one == "testone" and two == "testtwo" and three == "testthree": diff --git a/tests/test_parser.py b/tests/test_parser.py index 6f32b8fcc..307220900 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -43,7 +43,7 @@ def test_prepare_command_when_required_args(self): parts = ["runnable"] parser = CommandParser() parser.prepare_command(cmd, parts) - parsed_args = parser.parse_args(["runnable", "one", "two"]) + parsed_args = parser.parse_args(["runnable", "--one", "onetest", "--two", "twotest"]) assert parsed_args.func(parsed_args) == "success" def test_prepare_command_when_required_args_help_outputs_help(self, capsys): From 19485a3a73ccc8453b614c0ef767f2346d2a9817 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 19 May 2020 13:48:34 -0500 Subject: [PATCH 057/349] Feature/INTEG_978 handle keyboard interrupts (#60) * update ArgConfig to allow changing metavar setting * move verify_timestamp_order to date_helper module * - add AlertCursorStore class - refactor shared methods onto the base class - set default db name to "checkpoints.db" so we don't create separate dbs for each cursor store * - moved enums to shared module - added Alert enums * pull out shared extraction functions * move logger_factory to shared module * update securitydata extraction and main * add alerts extraction and main * add main alerts command * update setup.py * update some tests * update conftest * bump dependency versions * fix bugs in advanced_arg handling * extract shared search arguments into shared module * complete the warning change * fixed existing tests * fixed global error state flagging * rename shared func * more test fixes * Remove CEF option for alerts output * hard-code cursor_store db to `file_event_checkpoints.db` * update profile deletion to handle alert cursors * fix log method docstrings * fix import * logging adjustments * add new logging to alert extraction * update shared extraction logging * improve error handling * fix file event tests with new logging changes * add alert tests and reorganize a bit * update arg options * fix --exclude-actor-contains nargs * more test fixes/updates * fix alertstate choices in help * remove "Accepts multiple args." from help messages * enumerate rule types in help * rename shared folder to search_shared * rename shared folder to search_shared * YYYY-MM-DD -> yyyy-MM-dd, along with clarity on partial time, and remove duplication of arg format message. * move shared table init logic to base class * adjust DateArgumentException error message to use max_days_back var instead of hardcoded # * range compatibility * get alert details instead of summaries in extraction * update py42 minimum req * update changelog * implement clean interrupt handling * remove test code * docstring and changelog * remove a test thing * change decorator behavior to exit after function completes on first ctrl-c --- CHANGELOG.md | 3 ++ .../cmds/search_shared/extraction.py | 4 ++ src/code42cli/main.py | 9 ++++ src/code42cli/util.py | 47 +++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1be3099e..6eae88315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,9 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - A custom error in the error log when you try adding unknown risk tags to user. - A custom error in the error log when you try adding a user to a detection list who is already added. +- Graceful handling of keyboard interrupts (ctrl-c) so stack traces aren't printed to console. +- Warning message printed when ctrl-c is encountered in the middle of an operation that could cause incorrect checkpoint + state, a second ctrl-c is required to quit while that operation is ongoing. ### Fixed diff --git a/src/code42cli/cmds/search_shared/extraction.py b/src/code42cli/cmds/search_shared/extraction.py index ba43e4ba3..1c40f3162 100644 --- a/src/code42cli/cmds/search_shared/extraction.py +++ b/src/code42cli/cmds/search_shared/extraction.py @@ -7,6 +7,7 @@ from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp, verify_timestamp_order from code42cli.logger import get_main_cli_logger from code42cli.cmds.alerts.util import get_alert_details +from code42cli.util import warn_interrupt logger = get_main_cli_logger() @@ -50,6 +51,9 @@ def handle_error(exception): handlers.record_cursor_position = cursor_store.replace_stored_cursor_timestamp handlers.get_cursor_position = cursor_store.get_stored_cursor_timestamp + @warn_interrupt( + warning=u"Cancelling operation cleanly to keep checkpoint data accurate. One moment..." + ) def handle_response(response): response_dict = json.loads(response.text) events = response_dict.get(event_key) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index b185f8a40..1741d2a9a 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,4 +1,5 @@ import platform +import signal import sys from py42.settings import set_user_agent_suffix @@ -15,6 +16,14 @@ from code42cli.cmds.alerts.rules.commands import AlertRulesCommands +# Handle KeyboardInterrupts by just exiting instead of printing out a stack +def exit_on_interrupt(signal, frame): + sys.exit(1) + + +signal.signal(signal.SIGINT, exit_on_interrupt) + + # If on Windows, configure console session to handle ANSI escape sequences correctly # source: https://bugs.python.org/issue29059 if platform.system().lower() == u"windows": diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 30655972e..a592c41d3 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,6 +1,8 @@ from __future__ import print_function import sys +from functools import wraps from os import makedirs, path +from signal import signal, getsignal, SIGINT from code42cli.compat import open, str @@ -88,3 +90,48 @@ def format_to_table(rows, column_size): for key in row.keys(): print(str(row[key]).ljust(column_size[key] + _PADDING_SIZE), end=u" ") print(u"") + + +class warn_interrupt(object): + """A context decorator class used to wrap functions where a keyboard interrupt could potentially + leave things in a bad state. Warns the user with provided message and exits when wrapped + function is complete. Requires user to ctrl-c a second time to force exit. + + Usage: + + @warn_interrupt(warning="example message") + def my_important_func(): + pass + """ + + def __init__(self, warning="Cancelling operation cleanly, one moment... "): + self.warning = warning + self.old_handler = None + self.interrupted = False + self.exit_instructions = "Hit CTRL-C again to force quit." + + def __enter__(self): + self.old_handler = getsignal(SIGINT) + signal(SIGINT, self._handle_interrupts) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.interrupted: + exit(1) + signal(SIGINT, self.old_handler) + return False + + def _handle_interrupts(self, sig, frame): + if not self.interrupted: + self.interrupted = True + print("\n{}\n{}".format(self.warning, self.exit_instructions), file=sys.stderr) + else: + exit() + + def __call__(self, func): + @wraps(func) + def inner(*args, **kwds): + with self: + return func(*args, **kwds) + + return inner From 8d07ed3ff64d49989004358fb19cf1f56352cb3a Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Tue, 19 May 2020 16:11:17 -0500 Subject: [PATCH 058/349] Bugfix/error messages (#72) --- setup.py | 2 +- src/code42cli/cmds/alerts/rules/user_rule.py | 59 ++++++-- src/code42cli/cmds/detectionlists/__init__.py | 29 +--- .../cmds/search_shared/extraction.py | 2 +- src/code42cli/date_helper.py | 22 +-- src/code42cli/errors.py | 45 ++++++ src/code42cli/invoker.py | 5 + src/code42cli/worker.py | 6 + tests/cmds/alerts/rules/test_user_rule.py | 142 ++++++++++++++++-- tests/cmds/alerts/test_extraction.py | 6 +- .../detectionlists/test_departing_employee.py | 22 +-- .../detectionlists/test_high_risk_employee.py | 52 +------ tests/cmds/detectionlists/test_init.py | 12 +- tests/cmds/search_shared/test_date_helper.py | 8 +- tests/cmds/securitydata/test_extraction.py | 6 +- tests/test_invoker.py | 12 ++ 16 files changed, 262 insertions(+), 168 deletions(-) diff --git a/setup.py b/setup.py index 17142affa..348ceeaa8 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ "c42eventextractor==0.3.0b1", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42>=1.1.3", + "py42>=1.2.0", ], license="MIT", include_package_data=True, diff --git a/src/code42cli/cmds/alerts/rules/user_rule.py b/src/code42cli/cmds/alerts/rules/user_rule.py index 600a12516..ea9a42c77 100644 --- a/src/code42cli/cmds/alerts/rules/user_rule.py +++ b/src/code42cli/cmds/alerts/rules/user_rule.py @@ -1,5 +1,8 @@ +from py42.exceptions import Py42InternalServerError from py42.util import format_json + +from code42cli.errors import InvalidRuleTypeError from code42cli.util import format_to_table, find_format_width from code42cli.bulk import run_bulk_process, CSVReader from code42cli.logger import get_main_cli_logger @@ -19,29 +22,55 @@ def add_user(sdk, profile, rule_id, username): user_id = get_user_id(sdk, username) - sdk.alerts.rules.add_user(rule_id, user_id) + rules = _get_rule_metadata(sdk, rule_id) + try: + if rules: + sdk.alerts.rules.add_user(rule_id, user_id) + except Py42InternalServerError as e: + _check_if_system_rule(sdk, rules) + raise def remove_user(sdk, profile, rule_id, username): - if username: - user_id = get_user_id(sdk, username) - sdk.alerts.rules.remove_user(rule_id, user_id) - else: - sdk.alerts.rules.remove_all_users(rule_id) + user_id = get_user_id(sdk, username) + rules = _get_rule_metadata(sdk, rule_id) + try: + if rules: + sdk.alerts.rules.remove_user(rule_id, user_id) + except Py42InternalServerError as e: + _check_if_system_rule(sdk, rules) + raise -def _get_rules_metadata(sdk, rule_id=None): +def _get_all_rules_metadata(sdk): rules_generator = sdk.alerts.rules.get_all() selected_rules = [rule for rules in rules_generator for rule in rules[u"ruleMetadata"]] - if rule_id: - selected_rules = [rule for rule in selected_rules if rule[u"observerRuleId"] == rule_id] - return selected_rules + return _handle_rules_results(sdk, selected_rules) + + +def _get_rule_metadata(sdk, rule_id): + rules = sdk.alerts.rules.get_by_observer_id(rule_id)[u"ruleMetadata"] + return _handle_rules_results(sdk, rules, rule_id) + + +def _handle_rules_results(sdk, rules, rule_id=None): + id_msg = u"with RuleId {} ".format(rule_id) if rule_id else u"" + msg = u"No alert rules {0}found.".format(id_msg) + if not rules: + get_main_cli_logger().print_and_log_info(msg) + return rules + + +def _check_if_system_rule(sdk, rules): + if rules and rules[0][u"isSystem"]: + raise InvalidRuleTypeError(rules[0][u"observerRuleId"], rules[0][u"ruleSource"]) def get_rules(sdk, profile): - selected_rules = _get_rules_metadata(sdk) - rows, column_size = find_format_width(selected_rules, _HEADER_KEYS_MAP) - format_to_table(rows, column_size) + selected_rules = _get_all_rules_metadata(sdk) + if selected_rules: + rows, column_size = find_format_width(selected_rules, _HEADER_KEYS_MAP) + format_to_table(rows, column_size) def add_bulk_users(sdk, profile, file_name): @@ -59,9 +88,9 @@ def remove_bulk_users(sdk, profile, file_name): def show_rule(sdk, profile, rule_id): - selected_rule = _get_rules_metadata(sdk, rule_id) + selected_rule = _get_rule_metadata(sdk, rule_id) rule_detail = None - if len(selected_rule): + if selected_rule: rule_type = selected_rule[0][u"type"] if rule_type == AlertRuleTypes.EXFILTRATION: rule_detail = sdk.alerts.rules.exfiltration.get(rule_id) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index c6dbccb77..f4748b789 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -3,25 +3,11 @@ from code42cli.compat import str from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory from code42cli.bulk import generate_template, run_bulk_process, CSVReader, FlatFileReader -from code42cli.logger import get_main_cli_logger +from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError, UserDoesNotExistError from code42cli.bulk import BulkCommandType from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags -class UserAlreadyAddedError(Exception): - def __init__(self, username, list_name): - msg = u"'{}' is already on the {} list.".format(username, list_name) - super(UserAlreadyAddedError, self).__init__(msg) - - -class UnknownRiskTagError(Exception): - def __init__(self, bad_tags): - tags = u", ".join(bad_tags) - super(UnknownRiskTagError, self).__init__( - u"The following risk tags are unknown: '{}'.".format(tags) - ) - - def try_handle_user_already_added_error(bad_request_err, username_tried_adding, list_name): if _error_is_user_already_added(bad_request_err.response.text): raise UserAlreadyAddedError(username_tried_adding, list_name) @@ -32,15 +18,6 @@ def _error_is_user_already_added(bad_request_error_text): return u"User already on list" in bad_request_error_text -class UserDoesNotExistError(Exception): - """An error to represent a username that is not in our system. The CLI shows this error when - the user tries to add or remove a user that does not exist. This error is not shown during - bulk add or remove.""" - - def __init__(self, username): - super(UserDoesNotExistError, self).__init__(u"User '{}' does not exist.".format(username)) - - class DetectionListHandlers(object): """Handlers DTO for passing in specific detection list functions. @@ -206,9 +183,7 @@ def get_user_id(sdk, username): """ users = sdk.users.get_by_username(username)[u"users"] if not users: - ex = UserDoesNotExistError(username) - get_main_cli_logger().print_and_log_error(str(ex)) - raise ex + raise UserDoesNotExistError(username) return users[0][u"userUid"] diff --git a/src/code42cli/cmds/search_shared/extraction.py b/src/code42cli/cmds/search_shared/extraction.py index 1c40f3162..08641ce85 100644 --- a/src/code42cli/cmds/search_shared/extraction.py +++ b/src/code42cli/cmds/search_shared/extraction.py @@ -19,7 +19,7 @@ def begin_date_is_required(args, cursor_store): # Ignore begin date when in incremental mode, it is not required, and it was passed an argument. if not is_required and args.begin: - logger.print_info( + logger.print_and_log_info( u"Ignoring --begin value as --incremental was passed and cursor checkpoint exists.\n" ) args.begin = None diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py index c779b3b8c..10c651f7f 100644 --- a/src/code42cli/date_helper.py +++ b/src/code42cli/date_helper.py @@ -3,27 +3,17 @@ from c42eventextractor.common import convert_datetime_to_timestamp - -_FORMAT_VALUE_ERROR_MESSAGE = ( - u"input must be a date/time string (e.g. 'YYYY-MM-DD', " - u"'YY-MM-DD HH:MM', 'YY-MM-DD HH:MM:SS'), or a short value in days, " - u"hours, or minutes (e.g. 30d, 24h, 15m)" -) +from code42cli.errors import DateArgumentError TIMESTAMP_REGEX = re.compile(u"(\d{4}-\d{2}-\d{2})\s*(.*)?") MAGIC_TIME_REGEX = re.compile(u"(\d+)([dhm])$") -class DateArgumentException(Exception): - def __init__(self, message=_FORMAT_VALUE_ERROR_MESSAGE): - super(DateArgumentException, self).__init__(message) - - def verify_timestamp_order(min_timestamp, max_timestamp): if min_timestamp is None or max_timestamp is None: return if min_timestamp >= max_timestamp: - raise DateArgumentException(u"Begin date cannot be after end date") + raise DateArgumentError(u"Begin date cannot be after end date") def parse_min_timestamp(begin_date_str, max_days_back=90): @@ -31,7 +21,7 @@ def parse_min_timestamp(begin_date_str, max_days_back=90): boundary_date = _round_datetime_to_day_start(datetime.utcnow() - timedelta(days=max_days_back)) if dt < boundary_date: - raise DateArgumentException(u"'Begin date' must be within {0} days.".format(max_days_back)) + raise DateArgumentError(u"'Begin date' must be within {0} days.".format(max_days_back)) return convert_datetime_to_timestamp(dt) @@ -58,7 +48,7 @@ def _parse_timestamp(date_str, rounding_func): dt = rounding_func(dt) else: - raise DateArgumentException() + raise DateArgumentError() return dt @@ -72,7 +62,7 @@ def _get_dt_from_date_time_pair(date, time): try: dt = datetime.strptime(date_string, date_format) except ValueError: - raise DateArgumentException() + raise DateArgumentError() else: return dt @@ -86,7 +76,7 @@ def _get_dt_from_magic_time_pair(num, period): elif period == u"m": dt = datetime.utcnow() - timedelta(minutes=num) else: - raise DateArgumentException(u"Couldn't parse magic time string: {}{}".format(num, period)) + raise DateArgumentError(u"Couldn't parse magic time string: {}{}".format(num, period)) return dt diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index 112a3bbbb..eaf6c9f65 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -1 +1,46 @@ ERRORED = False + +_FORMAT_VALUE_ERROR_MESSAGE = ( + u"input must be a date/time string (e.g. 'YYYY-MM-DD', " + u"'YY-MM-DD HH:MM', 'YY-MM-DD HH:MM:SS'), or a short value in days, " + u"hours, or minutes (e.g. 30d, 24h, 15m)" +) + + +class Code42CLIError(Exception): + pass + + +class UserAlreadyAddedError(Code42CLIError): + def __init__(self, username, list_name): + msg = u"'{}' is already on the {} list.".format(username, list_name) + super(UserAlreadyAddedError, self).__init__(msg) + + +class UnknownRiskTagError(Code42CLIError): + def __init__(self, bad_tags): + tags = u", ".join(bad_tags) + super(UnknownRiskTagError, self).__init__( + u"The following risk tags are unknown: '{}'.".format(tags) + ) + + +class InvalidRuleTypeError(Code42CLIError): + def __init__(self, rule_id, source): + msg = u"Only alert rules with a source of 'Alerting' can be targeted by this command. " + msg += "Rule {0} has a source of '{1}'." + super(InvalidRuleTypeError, self).__init__(msg.format(rule_id, source)) + + +class UserDoesNotExistError(Code42CLIError): + """An error to represent a username that is not in our system. The CLI shows this error when + the user tries to add or remove a user that does not exist. This error is not shown during + bulk add or remove.""" + + def __init__(self, username): + super(UserDoesNotExistError, self).__init__(u"User '{}' does not exist.".format(username)) + + +class DateArgumentError(Code42CLIError): + def __init__(self, message=_FORMAT_VALUE_ERROR_MESSAGE): + super(DateArgumentError, self).__init__(message) diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py index a727afdea..d30accb69 100644 --- a/src/code42cli/invoker.py +++ b/src/code42cli/invoker.py @@ -2,6 +2,8 @@ from py42.exceptions import Py42HTTPError, Py42ForbiddenError +from code42cli.compat import str +from code42cli.errors import Code42CLIError from code42cli.parser import ArgumentParserError, CommandParser from code42cli.logger import get_main_cli_logger @@ -25,6 +27,9 @@ def run(self, input_args): path_parts = self._get_path_parts(input_args) command = self._commands.get(u" ".join(path_parts)) self._try_run_command(command, path_parts, input_args) + except Code42CLIError as err: + logger = get_main_cli_logger() + logger.print_and_log_error(str(err)) except Py42ForbiddenError as err: logger = get_main_cli_logger() logger.log_verbose_error(invocation_str, err.response.request) diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 8071fe985..050f1fdb7 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -2,6 +2,8 @@ from py42.exceptions import Py42HTTPError, Py42ForbiddenError +from code42cli.compat import str +from code42cli.errors import Code42CLIError from code42cli.compat import queue from code42cli.logger import get_main_cli_logger @@ -77,6 +79,10 @@ def _process_queue(self): args = task[u"args"] kwargs = task[u"kwargs"] func(*args, **kwargs) + except Code42CLIError as err: + self._increment_total_errors() + logger = get_main_cli_logger() + logger.log_error(err) except Py42ForbiddenError as err: self._increment_total_errors() logger = get_main_cli_logger() diff --git a/tests/cmds/alerts/rules/test_user_rule.py b/tests/cmds/alerts/rules/test_user_rule.py index 77534adc6..f0757eb8e 100644 --- a/tests/cmds/alerts/rules/test_user_rule.py +++ b/tests/cmds/alerts/rules/test_user_rule.py @@ -1,23 +1,57 @@ import pytest +from requests import Response, HTTPError +from py42.exceptions import Py42InternalServerError +from code42cli.errors import InvalidRuleTypeError from code42cli.cmds.alerts.rules.user_rule import add_user, remove_user, get_rules, show_rule +import logging + TEST_RULE_ID = u"rule-id" TEST_USER_ID = u"test-user-id" TEST_USERNAME = "test@code42.com" -TEST_GET_ALL_RESPONSE_EXFILTRATION = [ - {u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_ENDPOINT_EXFILTRATION"}]} -] - -TEST_GET_ALL_RESPONSE_CLOUD_SHARE = [ - {u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_CLOUD_SHARE_PERMISSIONS"}]} -] - - -TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH = [ - {u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_FILE_TYPE_MISMATCH"}]} -] +TEST_EMPTY_RULE_RESPONSE = {u"ruleMetadata": []} + +TEST_SYSTEM_RULE_REPONSE = { + u"ruleMetadata": [ + { + u"observerRuleId": TEST_RULE_ID, + u"type": u"FED_FILE_TYPE_MISMATCH", + u"isSystem": True, + u"ruleSource": "NOTVALID", + } + ] +} + +TEST_USER_RULE_REPONSE = { + u"ruleMetadata": [ + { + u"observerRuleId": TEST_RULE_ID, + u"type": u"FED_FILE_TYPE_MISMATCH", + u"isSystem": False, + u"ruleSource": "Testing", + } + ] +} + +TEST_GET_ALL_RESPONSE_EXFILTRATION = { + u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_ENDPOINT_EXFILTRATION"}] +} +TEST_GET_ALL_RESPONSE_CLOUD_SHARE = { + u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_CLOUD_SHARE_PERMISSIONS"}] +} +TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH = { + u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_FILE_TYPE_MISMATCH"}] +} + + +@pytest.fixture +def mock_server_error(mocker): + base_err = HTTPError() + mock_response = mocker.MagicMock(spec=Response) + base_err.response = mock_response + return Py42InternalServerError(base_err) def test_add_user_adds_user_list_to_alert_rules(alert_rules_sdk, profile): @@ -26,30 +60,108 @@ def test_add_user_adds_user_list_to_alert_rules(alert_rules_sdk, profile): alert_rules_sdk.alerts.rules.add_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) +def test_add_user_when_non_existent_alert_prints_no_rules_message(alert_rules_sdk, profile, caplog): + with caplog.at_level(logging.INFO): + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + add_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) + msg = u"No alert rules with RuleId {} found".format(TEST_RULE_ID) + assert msg in caplog.text + + +def test_add_user_when_returns_500_and_system_rule_raises_InvalidRuleTypeError( + alert_rules_sdk, profile, mock_server_error, caplog +): + with caplog.at_level(logging.INFO): + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_SYSTEM_RULE_REPONSE + alert_rules_sdk.alerts.rules.add_user.side_effect = mock_server_error + with pytest.raises(InvalidRuleTypeError): + add_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) + + +def test_add_user_when_returns_500_and_not_system_rule_raises_Py42InternalServerError( + alert_rules_sdk, profile, mock_server_error, caplog +): + with caplog.at_level(logging.INFO): + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_USER_RULE_REPONSE + alert_rules_sdk.alerts.rules.add_user.side_effect = mock_server_error + with pytest.raises(Py42InternalServerError): + add_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) + + def test_remove_user_removes_user_list_from_alert_rules(alert_rules_sdk, profile): alert_rules_sdk.users.get_by_username.return_value = {u"users": [{u"userUid": TEST_USER_ID}]} remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) alert_rules_sdk.alerts.rules.remove_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) +def test_remove_user_when_non_existent_alert_prints_no_rules_message( + alert_rules_sdk, profile, caplog +): + with caplog.at_level(logging.INFO): + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) + msg = u"No alert rules with RuleId {} found".format(TEST_RULE_ID) + assert msg in caplog.text + + +def test_remove_user_when_returns_500_and_system_rule_raises_InvalidRuleTypeError( + alert_rules_sdk, profile, mock_server_error, caplog +): + with caplog.at_level(logging.INFO): + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_SYSTEM_RULE_REPONSE + alert_rules_sdk.alerts.rules.remove_user.side_effect = mock_server_error + with pytest.raises(InvalidRuleTypeError): + remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) + + +def test_remove_user_when_returns_500_and_not_system_rule_raises_Py42InternalServerError( + alert_rules_sdk, profile, mock_server_error, caplog +): + with caplog.at_level(logging.INFO): + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_USER_RULE_REPONSE + alert_rules_sdk.alerts.rules.remove_user.side_effect = mock_server_error + with pytest.raises(Py42InternalServerError): + remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) + + def test_get_rules_gets_alert_rules(alert_rules_sdk, profile): get_rules(alert_rules_sdk, profile) assert alert_rules_sdk.alerts.rules.get_all.call_count == 1 +def test_get_rules_when_no_rules_prints_no_rules_message(alert_rules_sdk, profile, caplog): + with caplog.at_level(logging.INFO): + alert_rules_sdk.alerts.rules.get_all.return_value = [TEST_EMPTY_RULE_RESPONSE] + get_rules(alert_rules_sdk, profile) + msg = u"No alert rules found".format(TEST_RULE_ID) + assert msg in caplog.text + + def test_show_rule_calls_correct_rule_property(alert_rules_sdk, profile): - alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_EXFILTRATION + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_GET_ALL_RESPONSE_EXFILTRATION + ) show_rule(alert_rules_sdk, profile, TEST_RULE_ID) alert_rules_sdk.alerts.rules.exfiltration.get.assert_called_once_with(TEST_RULE_ID) def test_show_rule_calls_correct_rule_property_cloud_share(alert_rules_sdk, profile): - alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_CLOUD_SHARE + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_GET_ALL_RESPONSE_CLOUD_SHARE show_rule(alert_rules_sdk, profile, TEST_RULE_ID) alert_rules_sdk.alerts.rules.cloudshare.get.assert_called_once_with(TEST_RULE_ID) def test_show_rule_calls_correct_rule_property_file_type_mismatch(alert_rules_sdk, profile): - alert_rules_sdk.alerts.rules.get_all.return_value = TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH + ) show_rule(alert_rules_sdk, profile, TEST_RULE_ID) alert_rules_sdk.alerts.rules.filetypemismatch.get.assert_called_once_with(TEST_RULE_ID) + + +def test_show_rule_when_no_matching_rule_prints_no_rule_message(alert_rules_sdk, profile, caplog): + with caplog.at_level(logging.INFO): + alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + show_rule(alert_rules_sdk, profile, TEST_RULE_ID) + msg = u"No alert rules with RuleId {} found".format(TEST_RULE_ID) + assert msg in caplog.text diff --git a/tests/cmds/alerts/test_extraction.py b/tests/cmds/alerts/test_extraction.py index d8253a12a..91427dc59 100644 --- a/tests/cmds/alerts/test_extraction.py +++ b/tests/cmds/alerts/test_extraction.py @@ -7,7 +7,7 @@ import code42cli.cmds.alerts.extraction as extraction_module import code42cli.errors as errors from code42cli import PRODUCT_NAME -from code42cli.date_helper import DateArgumentException +from code42cli.errors import DateArgumentError from tests.cmds.conftest import get_filter_value_from_json from ...conftest import get_test_date_str, begin_date_str, ErrorTrackerTestHelper @@ -237,7 +237,7 @@ def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_m alert_namespace.incremental = False date = get_test_date_str(days_ago=91) + " 12:51:00" alert_namespace.begin = date - with pytest.raises(DateArgumentException): + with pytest.raises(DateArgumentError): extraction_module.extract(sdk, profile, logger, alert_namespace) @@ -246,7 +246,7 @@ def test_extract_when_end_date_is_before_begin_date_causes_exit( ): alert_namespace.begin = get_test_date_str(days_ago=5) alert_namespace.end = get_test_date_str(days_ago=6) - with pytest.raises(DateArgumentException): + with pytest.raises(DateArgumentError): extraction_module.extract(sdk, profile, logger, alert_namespace) diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py index 957c98775..67ec1c4b8 100644 --- a/tests/cmds/detectionlists/test_departing_employee.py +++ b/tests/cmds/detectionlists/test_departing_employee.py @@ -1,7 +1,7 @@ import pytest import logging -from code42cli.cmds.detectionlists import UserDoesNotExistError, UserAlreadyAddedError +from code42cli.errors import UserAlreadyAddedError, UserDoesNotExistError from code42cli.cmds.detectionlists.departing_employee import ( add_departing_employee, remove_departing_employee, @@ -39,16 +39,6 @@ def test_add_departing_employee_when_user_does_not_exist_exits(sdk_without_user, add_departing_employee(sdk_without_user, profile, _EMPLOYEE) -def test_add_departing_employee_when_user_does_not_exist_prints_error( - sdk_without_user, profile, caplog -): - with caplog.at_level(logging.ERROR): - try: - add_departing_employee(sdk_without_user, profile, _EMPLOYEE) - except UserDoesNotExistError: - assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text - - def test_add_departing_employee_when_user_already_added_raises_UserAlreadyAddedError( sdk_with_user, profile, bad_request_for_user_already_added ): @@ -75,13 +65,3 @@ def test_remove_departing_employee_calls_remove(sdk_with_user, profile): def test_remove_departing_employee_when_user_does_not_exist_exits(sdk_without_user, profile): with pytest.raises(UserDoesNotExistError): remove_departing_employee(sdk_without_user, profile, _EMPLOYEE) - - -def test_remove_departing_employee_when_user_does_not_exist_prints_error( - sdk_without_user, profile, caplog -): - with caplog.at_level(logging.ERROR): - try: - remove_departing_employee(sdk_without_user, profile, _EMPLOYEE) - except UserDoesNotExistError: - assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index a7126d8ec..76ab69932 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -1,11 +1,7 @@ import pytest import logging -from code42cli.cmds.detectionlists import ( - UserDoesNotExistError, - UserAlreadyAddedError, - UnknownRiskTagError, -) +from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError, UserDoesNotExistError from code42cli.cmds.detectionlists.high_risk_employee import ( add_high_risk_employee, remove_high_risk_employee, @@ -56,16 +52,6 @@ def test_add_high_risk_employee_when_user_does_not_exist_exits(sdk_without_user, add_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) -def test_add_high_risk_employee_when_user_does_not_exist_prints_error( - sdk_without_user, profile, caplog -): - with caplog.at_level(logging.ERROR): - try: - add_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) - except UserDoesNotExistError: - assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text - - def test_add_high_risk_employee_when_user_already_added_raises_UserAlreadyAddedError( sdk_with_user, profile, bad_request_for_user_already_added ): @@ -123,16 +109,6 @@ def test_remove_high_risk_employee_when_user_does_not_exist_exits(sdk_without_us remove_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) -def test_remove_high_risk_employee_when_user_does_not_exist_prints_error( - sdk_without_user, profile, caplog -): - with caplog.at_level(logging.ERROR): - try: - remove_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) - except UserDoesNotExistError: - assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text - - def test_add_risk_tags_adds_tags(sdk_with_user, profile): add_risk_tags( sdk_with_user, @@ -167,19 +143,6 @@ def test_add_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile) ) -def test_add_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, caplog): - with caplog.at_level(logging.ERROR): - try: - add_risk_tags( - sdk_without_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - except UserDoesNotExistError: - assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text - - def test_add_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( sdk_with_user, profile, generic_bad_request ): @@ -231,19 +194,6 @@ def test_remove_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profi ) -def test_remove_risk_tags_when_user_does_not_exist_prints_error(sdk_without_user, profile, caplog): - with caplog.at_level(logging.ERROR): - try: - remove_risk_tags( - sdk_without_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - except UserDoesNotExistError: - assert str(UserDoesNotExistError(_EMPLOYEE)) in caplog.text - - def test_remove_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( sdk_with_user, profile, generic_bad_request ): diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 5ea4e6987..79ab7f8c6 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -8,12 +8,10 @@ DetectionListHandlers, get_user_id, update_user, - UserDoesNotExistError, try_add_risk_tags, try_remove_risk_tags, - UnknownRiskTagError, - UserAlreadyAddedError, ) +from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError, UserDoesNotExistError from code42cli.bulk import BulkCommandType from code42cli.cmds.detectionlists.enums import RiskTags from .conftest import TEST_ID @@ -50,14 +48,6 @@ def test_get_user_id_when_user_does_not_raise_error(sdk_without_user): get_user_id(sdk_without_user, "risky employee") -def test_get_user_id_when_user_does_not_exist_logs_error(sdk_without_user, caplog): - with caplog.at_level(logging.ERROR): - try: - get_user_id(sdk_without_user, "risky employee") - except UserDoesNotExistError: - assert "User 'risky employee' does not exist." in caplog.text - - def test_update_user_adds_cloud_alias(sdk_with_user, profile): update_user(sdk_with_user, TEST_ID, cloud_alias="1@example.com") sdk_with_user.detectionlists.add_user_cloud_alias.assert_called_once_with( diff --git a/tests/cmds/search_shared/test_date_helper.py b/tests/cmds/search_shared/test_date_helper.py index 4162f81ee..69678cb16 100644 --- a/tests/cmds/search_shared/test_date_helper.py +++ b/tests/cmds/search_shared/test_date_helper.py @@ -1,6 +1,6 @@ import pytest -from code42cli.date_helper import DateArgumentException +from code42cli.errors import DateArgumentError from code42cli.cmds.search_shared.extraction import create_time_range_filter from py42.sdk.queries.fileevents.filters import InsertionTimestamp, EventTimestamp from py42.sdk.queries.alerts.filters import DateObserved @@ -100,7 +100,7 @@ def test_create_event_timestamp_filter_when_begin_more_than_ninety_days_back_cau timestamp_filter_class, ): begin_date_str = get_test_date_str(days_ago=91) - with pytest.raises(DateArgumentException): + with pytest.raises(DateArgumentError): create_time_range_filter(timestamp_filter_class, begin_date_str) @@ -110,7 +110,7 @@ def test_create_event_timestamp_filter_when_end_is_before_begin_causes_value_err ): begin_date = get_test_date_str(days_ago=5) end_date = get_test_date_str(days_ago=7) - with pytest.raises(DateArgumentException): + with pytest.raises(DateArgumentError): create_time_range_filter(timestamp_filter_class, begin_date, end_date) @@ -143,5 +143,5 @@ def test_create_event_timestamp_filter_when_args_are_magic_days_builds_expected_ def test_create_event_timestamp_filter_when_given_improperly_formatted_arg_raises_value_error( bad_date_param, timestamp_filter_class ): - with pytest.raises(DateArgumentException): + with pytest.raises(DateArgumentError): create_time_range_filter(timestamp_filter_class, bad_date_param) diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index aa064f434..d780e86e4 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -8,7 +8,7 @@ from code42cli import PRODUCT_NAME from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions from tests.cmds.conftest import get_filter_value_from_json -from code42cli.date_helper import DateArgumentException +from code42cli.errors import DateArgumentError from ...conftest import get_test_date_str, begin_date_str, ErrorTrackerTestHelper @@ -249,7 +249,7 @@ def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_m file_event_namespace.incremental = False date = get_test_date_str(days_ago=91) + " 12:51:00" file_event_namespace.begin = date - with pytest.raises(DateArgumentException): + with pytest.raises(DateArgumentError): extraction_module.extract(sdk, profile, logger, file_event_namespace) @@ -258,7 +258,7 @@ def test_extract_when_end_date_is_before_begin_date_causes_exit( ): file_event_namespace.begin = get_test_date_str(days_ago=5) file_event_namespace.end = get_test_date_str(days_ago=6) - with pytest.raises(DateArgumentException): + with pytest.raises(DateArgumentError): extraction_module.extract(sdk, profile, logger, file_event_namespace) diff --git a/tests/test_invoker.py b/tests/test_invoker.py index 3dddfcebc..f7ad174dc 100644 --- a/tests/test_invoker.py +++ b/tests/test_invoker.py @@ -7,6 +7,7 @@ from py42.exceptions import Py42ForbiddenError from code42cli.commands import Command +from code42cli.errors import Code42CLIError from code42cli.invoker import CommandInvoker from code42cli.parser import ArgumentParserError, CommandParser @@ -134,3 +135,14 @@ def test_run_when_forbidden_error_occurs_logs_request(self, mocker, mock_parser, with caplog.at_level(logging.ERROR): invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) assert str(request.body) in caplog.text + + def test_run_when_cli_error_occurs_logs_request(self, mocker, mock_parser, caplog): + cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + mock_parser.parse_args.side_effect = Code42CLIError("a code42cli error") + mock_subparser = mocker.MagicMock() + mock_parser.prepare_command.return_value = mock_subparser + invoker = CommandInvoker(cmd, mock_parser) + + with caplog.at_level(logging.ERROR): + invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) + assert "a code42cli error" in caplog.text From 9623d1f9e268fec8ae009d2718a4ea9eda074ae4 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 20 May 2020 15:23:47 -0500 Subject: [PATCH 059/349] fix use_profile func to take new expected arg name (#76) --- src/code42cli/cmds/profile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index d3d061830..d3c0abad4 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -65,7 +65,7 @@ def load_subcommands(): delete = Command( u"delete", - "Deletes a profile and its stored password (if any).", + u"Deletes a profile and its stored password (if any).", u"{} {}".format(usage_prefix, u"delete "), handler=delete_profile, ) @@ -135,9 +135,9 @@ def list_profiles(*args): logger.print_info(str(profile)) -def use_profile(profile): +def use_profile(name): """Changes the default profile to the given one.""" - cliprofile.switch_default_profile(profile) + cliprofile.switch_default_profile(name) def delete_profile(name): From 8814c9e33bc79be601dbebe0c30c28120ab5825e Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 21 May 2020 10:29:00 -0700 Subject: [PATCH 060/349] Feature/progress bar print (#71) --- CHANGELOG.md | 2 + src/code42cli/bulk.py | 69 ++++----- src/code42cli/cmds/alerts/rules/user_rule.py | 15 +- src/code42cli/cmds/detectionlists/__init__.py | 17 +-- src/code42cli/compat.py | 5 +- src/code42cli/errors.py | 12 ++ src/code42cli/file_readers.py | 56 +++++++ src/code42cli/logger.py | 36 ++++- src/code42cli/progress_bar.py | 29 ++++ src/code42cli/util.py | 4 + src/code42cli/worker.py | 35 +++-- tests/cmds/detectionlists/test_init.py | 19 ++- tests/conftest.py | 15 ++ tests/test_bulk.py | 137 ++++++++---------- tests/test_logger.py | 34 +++++ tests/test_progress_bar.py | 29 ++++ tests/test_worker.py | 2 +- 17 files changed, 358 insertions(+), 158 deletions(-) create mode 100644 src/code42cli/file_readers.py create mode 100644 src/code42cli/progress_bar.py create mode 100644 tests/test_progress_bar.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eae88315..24f988719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Warning message printed when ctrl-c is encountered in the middle of an operation that could cause incorrect checkpoint state, a second ctrl-c is required to quit while that operation is ongoing. +- A progress bar that displays during bulk commands. + ### Fixed - Fixed bug in bulk commands where value-less fields in csv files were treated as empty strings instead of None. diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index b2333e83e..4c8c68de2 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -1,11 +1,13 @@ -import os -import inspect -import csv +import os, inspect from code42cli.compat import open, str from code42cli.worker import Worker from code42cli.logger import get_main_cli_logger from code42cli.args import SDK_ARG_NAME, PROFILE_ARG_NAME +from code42cli.progress_bar import ProgressBar + + +_logger = get_main_cli_logger() class BulkCommandType(object): @@ -29,7 +31,7 @@ def generate_template(handler, path=None): ] if len(args) <= 1: - get_main_cli_logger().print_info( + _logger.print_info( u"A blank file was generated because there are no csv headers needed for this command. " u"Simply enter one {} per line.".format(args[0]) ) @@ -45,31 +47,28 @@ def _write_template_file(path, columns=None): new_file.write(u",".join(columns)) -def run_bulk_process(file_path, row_handler, reader=None): +def run_bulk_process(row_handler, reader): """Runs a bulk process. Args: - file_path (str or unicode): The path to the file feeding the data for the bulk process. row_handler (callable): A callable that you define to process values from the row as either *args or **kwargs. reader: (CSVReader or FlatFileReader, optional): A generator that reads rows and yields data into `row_handler`. If None, it will use a CSVReader. Defaults to None. """ - reader = reader or CSVReader() - processor = _create_bulk_processor(file_path, row_handler, reader) + processor = _create_bulk_processor(row_handler, reader) processor.run() -def _create_bulk_processor(file_path, row_handler, reader): +def _create_bulk_processor(row_handler, reader): """A factory method to create the bulk processor, useful for testing purposes.""" - return BulkProcessor(file_path, row_handler, reader) + return BulkProcessor(row_handler, reader) class BulkProcessor(object): """A class for bulk processing a file. Args: - file_path (str or unicode): The path to the file for processing. row_handler (callable): A callable that you define to process values from the row as either *args or **kwargs. For example, if it's a csv file with header `prop_a,prop_b` and first row `1,test`, then `row_handler` should receive kwargs @@ -78,11 +77,14 @@ class BulkProcessor(object): reader (CSVReader or FlatFileReader): A generator that reads rows and yields data into `row_handler`. """ - def __init__(self, file_path, row_handler, reader): - self.file_path = file_path + def __init__(self, row_handler, reader, worker=None, progress_bar=None): + total = reader.get_rows_count() + self.file_path = reader.file_path self._row_handler = row_handler self._reader = reader - self.__worker = Worker(5) + self.__worker = worker or Worker(5, total) + self._stats = self.__worker.stats + self._progress_bar = progress_bar or ProgressBar(total) def run(self): """Processes the csv file specified in the ctor, calling `self.row_handler` on each row.""" @@ -90,7 +92,7 @@ def run(self): for row in self._reader(bulk_file=bulk_file): self._process_row(row) self.__worker.wait() - self._print_result() + self._print_results() def _process_row(self, row): if isinstance(row, dict): @@ -104,35 +106,20 @@ def _process_csv_row(self, row): row.pop(None, None) row_values = {key: val if val != u"" else None for key, val in row.items()} self.__worker.do_async( - lambda *args, **kwargs: self._row_handler(*args, **kwargs), **row_values + lambda *args, **kwargs: self._handle_row(*args, **kwargs), **row_values ) def _process_flat_file_row(self, row): if row: - self.__worker.do_async(lambda *args, **kwargs: self._row_handler(*args, **kwargs), row) - - def _print_result(self): - stats = self.__worker.stats - successes = stats.total - stats.total_errors - logger = get_main_cli_logger() - logger.print_and_log_info( - u"{} processed successfully out of {}.".format(successes, stats.total) - ) - if stats.total_errors: - logger.print_errors_occurred_message() - + self.__worker.do_async(lambda *args, **kwargs: self._handle_row(*args, **kwargs), row) -class CSVReader(object): - """A generator that yields header keys mapped to row values from a csv file.""" + def _handle_row(self, *args, **kwargs): + message = str(self._stats) + self._progress_bar.update(self._stats.total_processed, message) + self._row_handler(*args, **kwargs) - def __call__(self, *args, **kwargs): - for row in csv.DictReader(kwargs.get(u"bulk_file")): - yield row - - -class FlatFileReader(object): - """A generator that yields a single-value per row from a file.""" - - def __call__(self, *args, **kwargs): - for row in kwargs[u"bulk_file"]: - yield row + def _print_results(self): + self._progress_bar.clear_bar_and_print_final(str(self._stats)) + if self._stats.total_errors: + logger = get_main_cli_logger() + logger.print_errors_occurred_message() diff --git a/src/code42cli/cmds/alerts/rules/user_rule.py b/src/code42cli/cmds/alerts/rules/user_rule.py index ea9a42c77..49ecf5882 100644 --- a/src/code42cli/cmds/alerts/rules/user_rule.py +++ b/src/code42cli/cmds/alerts/rules/user_rule.py @@ -4,7 +4,8 @@ from code42cli.errors import InvalidRuleTypeError from code42cli.util import format_to_table, find_format_width -from code42cli.bulk import run_bulk_process, CSVReader +from code42cli.bulk import run_bulk_process +from code42cli.file_readers import create_csv_reader from code42cli.logger import get_main_cli_logger from code42cli.cmds.detectionlists import get_user_id from code42cli.cmds.alerts.rules.enums import AlertRuleTypes @@ -74,17 +75,13 @@ def get_rules(sdk, profile): def add_bulk_users(sdk, profile, file_name): - run_bulk_process( - file_name, lambda rule_id, username: add_user(sdk, profile, rule_id, username), CSVReader() - ) + reader = create_csv_reader(file_name) + run_bulk_process(lambda rule_id, username: add_user(sdk, profile, rule_id, username), reader) def remove_bulk_users(sdk, profile, file_name): - run_bulk_process( - file_name, - lambda rule_id, username: remove_user(sdk, profile, rule_id, username), - CSVReader(), - ) + reader = create_csv_reader(file_name) + run_bulk_process(lambda rule_id, username: remove_user(sdk, profile, rule_id, username), reader) def show_rule(sdk, profile, rule_id): diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index f4748b789..6c4a225d9 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,10 +1,9 @@ from py42.exceptions import Py42BadRequestError -from code42cli.compat import str +from code42cli.bulk import generate_template, run_bulk_process, BulkCommandType +from code42cli.file_readers import create_csv_reader, create_flat_file_reader +from code42cli.errors import UserAlreadyAddedError, UserDoesNotExistError, UnknownRiskTagError from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory -from code42cli.bulk import generate_template, run_bulk_process, CSVReader, FlatFileReader -from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError, UserDoesNotExistError -from code42cli.bulk import BulkCommandType from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags @@ -123,9 +122,8 @@ def bulk_add_employees(self, sdk, profile, csv_file): profile (Code42Profile): The profile under which to execute this command. csv_file (str or unicode): The path to the csv file containing rows of users. """ - run_bulk_process( - csv_file, lambda **kwargs: self._add_employee(sdk, profile, **kwargs), CSVReader() - ) + reader = create_csv_reader(csv_file) + run_bulk_process(lambda **kwargs: self._add_employee(sdk, profile, **kwargs), reader) def bulk_remove_employees(self, sdk, profile, users_file): """Takes a flat file with each row containing a username and removes them all from the @@ -136,10 +134,9 @@ def bulk_remove_employees(self, sdk, profile, users_file): profile (Code42Profile): The profile under which to execute this command. users_file (str or unicode): The path to the file containing rows of user names. """ + reader = create_flat_file_reader(users_file) run_bulk_process( - users_file, - lambda *args, **kwargs: self._remove_employee(sdk, profile, *args, **kwargs), - FlatFileReader(), + lambda *args, **kwargs: self._remove_employee(sdk, profile, *args, **kwargs), reader ) def _add_employee(self, sdk, profile, **kwargs): diff --git a/src/code42cli/compat.py b/src/code42cli/compat.py index 52484211f..69ce05589 100644 --- a/src/code42cli/compat.py +++ b/src/code42cli/compat.py @@ -14,7 +14,10 @@ if is_py2: from urlparse import urljoin, urlparse - str = unicode + def _str(obj): + return unicode(obj) + + str = _str import io diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index eaf6c9f65..bc320ad67 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -1,5 +1,6 @@ ERRORED = False + _FORMAT_VALUE_ERROR_MESSAGE = ( u"input must be a date/time string (e.g. 'YYYY-MM-DD', " u"'YY-MM-DD HH:MM', 'YY-MM-DD HH:MM:SS'), or a short value in days, " @@ -7,6 +8,17 @@ ) +class BadFileError(Exception): + def __init__(self, file_path, *args, **kwargs): + self.file_path = file_path + super(BadFileError, self).__init__() + + +class EmptyFileError(BadFileError): + def __init__(self, file_path): + super(EmptyFileError, self).__init__(file_path, u"Given empty file {}.".format(file_path)) + + class Code42CLIError(Exception): pass diff --git a/src/code42cli/file_readers.py b/src/code42cli/file_readers.py new file mode 100644 index 000000000..59e889c7b --- /dev/null +++ b/src/code42cli/file_readers.py @@ -0,0 +1,56 @@ +import csv + +from code42cli.errors import BadFileError + + +class CliFileReader(object): + _ROWS_COUNT = -1 + + def __init__(self, file_path): + self.file_path = file_path + + def __call__(self, *args, **kwargs): + pass + + def get_rows_count(self): + if self._ROWS_COUNT == -1: + self._ROWS_COUNT = sum(1 for _ in open(self.file_path)) + if self._ROWS_COUNT == 0: + raise BadFileError(u"Given empty file {}.".format(self.file_path)) + return self._ROWS_COUNT + + +class CSVReader(CliFileReader): + """A generator that yields header keys mapped to row values from a csv file.""" + + def __init__(self, file_path): + with open(file_path) as f: + try: + self.has_header = csv.Sniffer().has_header(next(f)) + except StopIteration: + raise BadFileError(u"Given empty file {}.".format(file_path)) + super(CSVReader, self).__init__(file_path) + + def __call__(self, *args, **kwargs): + for row in csv.DictReader(kwargs.get(u"bulk_file")): + yield row + + def get_rows_count(self): + rows_count = super(CSVReader, self).get_rows_count() + return rows_count - 1 if self.has_header else rows_count + + +class FlatFileReader(CliFileReader): + """A generator that yields a single-value per row from a file.""" + + def __call__(self, *args, **kwargs): + for row in kwargs[u"bulk_file"]: + yield row + + +def create_csv_reader(file_path): + return CSVReader(file_path) + + +def create_flat_file_reader(file_path): + return FlatFileReader(file_path) diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 27da13da0..e64264546 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -4,7 +4,7 @@ import copy from code42cli.compat import str -from code42cli.util import get_user_project_path, is_interactive +from code42cli.util import get_user_project_path, is_interactive, color_text_red logger_deps_lock = Lock() @@ -124,7 +124,39 @@ def _create_formatter_for_error_file(): def _get_red_error_text(text): - return u"\033[91mERROR: {}\033[0m".format(text) + return color_text_red(u"ERROR: {}".format(text)) + + +def get_progress_logger(handler=None): + logger = logging.getLogger(u"code42cli_progress_bar") + if logger_has_handlers(logger): + return logger + + with logger_deps_lock: + if not logger_has_handlers(logger): + handler = handler or InPlaceStreamHandler() + formatter = _get_standard_formatter() + logger.setLevel(logging.INFO) + return add_handler_to_logger(logger, handler, formatter) + return logger + + +class InPlaceStreamHandler(logging.StreamHandler): + def __init__(self): + super(InPlaceStreamHandler, self).__init__(sys.stdout) + + def emit(self, record): + # Borrowed some from python3's logging.StreamHandler to make work on python2. + try: + msg = u"\r{}\r".format(self.format(record)) + stream = self.stream + stream.write(msg) + self.flush() + except RuntimeError as err: + if u"recursion" in str(err): + raise + except Exception: + self.handleError(record) class CliLogger(object): diff --git a/src/code42cli/progress_bar.py b/src/code42cli/progress_bar.py new file mode 100644 index 000000000..9fdbe053a --- /dev/null +++ b/src/code42cli/progress_bar.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +from code42cli.logger import get_main_cli_logger, get_progress_logger + + +class ProgressBar(object): + _FILL = u"█" + _LENGTH = 100 + + def __init__(self, total_items, logger=None): + self._total_items = total_items + self._logger = logger or get_progress_logger() + + def update(self, iteration, message): + bar = self._create_bar(iteration) + progress = u"{} {} ".format(bar, message) + self._logger.info(progress) + + def _create_bar(self, iteration): + fill_length = self._calculate_fill_length(iteration) + return self._FILL * fill_length + u"-" * (self._LENGTH - fill_length) + + def _calculate_fill_length(self, idx): + filled_length = int(self._LENGTH * idx // self._total_items) + return filled_length + + def clear_bar_and_print_final(self, final_message): + clear = (self._LENGTH + len(final_message)) * u" " + self._logger.info(u"{}{}\n".format(final_message, clear)) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index a592c41d3..1b5f4184b 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -92,6 +92,10 @@ def format_to_table(rows, column_size): print(u"") +def color_text_red(text): + return u"\033[91m{}\033[0m".format(text) + + class warn_interrupt(object): """A context decorator class used to wrap functions where a keyboard interrupt could potentially leave things in a bad state. Warns the user with provided message and exits when wrapped diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 050f1fdb7..81d7cb27f 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -2,7 +2,6 @@ from py42.exceptions import Py42HTTPError, Py42ForbiddenError -from code42cli.compat import str from code42cli.errors import Code42CLIError from code42cli.compat import queue from code42cli.logger import get_main_cli_logger @@ -11,25 +10,37 @@ class WorkerStats(object): """Stats about the tasks that have run.""" - _total = 0 + def __init__(self, total): + self.total = total + + _total_processed = 0 _total_errors = 0 - __total_lock = Lock() + __total_processed_lock = Lock() __total_errors_lock = Lock() @property - def total(self): + def total_processed(self): """The total number of tasks executed.""" - return self._total + return self._total_processed @property def total_errors(self): """The amount of errors that occurred.""" return self._total_errors - def increment_total(self): - """+1 to self.total""" - with self.__total_lock: - self._total += 1 + @property + def total_successes(self): + return self._total_processed - self._total_errors + + def __str__(self): + return u"{0} succeeded, {1} failed out of {2}".format( + self.total_successes, self._total_errors, self.total + ) + + def increment_total_processed(self): + """+1 to self.total_processed""" + with self.__total_processed_lock: + self._total_processed += 1 def increment_total_errors(self): """+1 to self.total_errors""" @@ -38,10 +49,10 @@ def increment_total_errors(self): class Worker(object): - def __init__(self, thread_count): + def __init__(self, thread_count, expected_total): self._queue = queue.Queue() self._thread_count = thread_count - self._stats = WorkerStats() + self._stats = WorkerStats(expected_total) self.__started = False self.__start_lock = Lock() @@ -97,7 +108,7 @@ def _process_queue(self): logger = get_main_cli_logger() logger.log_verbose_error() finally: - self._stats.increment_total() + self._stats.increment_total_processed() self._queue.task_done() def __start(self): diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 79ab7f8c6..55aaa6629 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -15,6 +15,7 @@ from code42cli.bulk import BulkCommandType from code42cli.cmds.detectionlists.enums import RiskTags from .conftest import TEST_ID +from ...conftest import create_mock_reader _NAMESPACE = "{}.cmds.detectionlists".format(PRODUCT_NAME) @@ -126,12 +127,22 @@ def a_test_func(): detection_list.generate_template_file(BulkCommandType.REMOVE, path) bulk_template_generator.assert_called_once_with(a_test_func, path) - def test_bulk_add_employees_uses_csv_path(self, sdk, profile, bulk_processor): + def test_bulk_add_employees_uses_expected_arguments(self, mocker, sdk, profile, bulk_processor): + reader = create_mock_reader([{"test": "value"}]) + reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) + reader_factory.return_value = reader detection_list = DetectionList("TestList", DetectionListHandlers()) detection_list.bulk_add_employees(sdk, profile, "csv_test") - assert bulk_processor.call_args[0][0] == "csv_test" + assert bulk_processor.call_args[0][1] == reader + reader_factory.assert_called_once_with("csv_test") - def test_bulk_remove_employees_uses_file_path(self, sdk, profile, bulk_processor): + def test_bulk_remove_employees_uses_expected_arguments( + self, mocker, sdk, profile, bulk_processor + ): + reader = create_mock_reader(["test1", "test2"]) + reader_factory = mocker.patch("{}.create_flat_file_reader".format(_NAMESPACE)) + reader_factory.return_value = reader detection_list = DetectionList("TestList", DetectionListHandlers()) detection_list.bulk_remove_employees(sdk, profile, "file_test") - assert bulk_processor.call_args[0][0] == "file_test" + assert bulk_processor.call_args[0][1] == reader + reader_factory.assert_called_once_with("file_test") diff --git a/tests/conftest.py b/tests/conftest.py index 7d5de7bc9..6adedbc42 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import pytest from py42.sdk import SDKClient +from code42cli.file_readers import CliFileReader from code42cli.config import ConfigAccessor from code42cli.profile import Code42Profile from code42cli.commands import DictObject @@ -196,3 +197,17 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): error_tracker.ERRORED = False + + +TEST_FILE_PATH = "some/path" + + +def create_mock_reader(rows): + class MockDictReader(CliFileReader): + def __call__(self, *args, **kwargs): + return rows + + def get_rows_count(self): + return len(rows) + + return MockDictReader(TEST_FILE_PATH) diff --git a/tests/test_bulk.py b/tests/test_bulk.py index edfc70f17..0c157ea46 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -5,10 +5,11 @@ from code42cli import PRODUCT_NAME from code42cli import errors as errors -from code42cli.bulk import generate_template, BulkProcessor, run_bulk_process, CSVReader +from code42cli.bulk import generate_template, BulkProcessor, run_bulk_process from code42cli.logger import get_view_exceptions_location_message +from code42cli.progress_bar import ProgressBar -from .conftest import ErrorTrackerTestHelper +from .conftest import ErrorTrackerTestHelper, create_mock_reader _NAMESPACE = "{}.bulk".format(PRODUCT_NAME) @@ -33,6 +34,11 @@ def bulk_processor_factory(mocker, bulk_processor): return mock_factory +@pytest.fixture +def progress_bar(mocker): + return mocker.MagicMock(spec=ProgressBar) + + def func_with_multiple_args(sdk, profile, test1, test2): pass @@ -81,89 +87,71 @@ def test_generate_template_when_handler_has_more_than_one_arg_does_not_print_mes def test_run_bulk_process_calls_run(bulk_processor, bulk_processor_factory): errors.ERRORED = False - run_bulk_process("some/path", func_with_one_arg, None) + run_bulk_process(func_with_one_arg, None) assert bulk_processor.run.call_count def test_run_bulk_process_creates_processor(bulk_processor_factory): errors.ERRORED = False - reader = CSVReader() - run_bulk_process("some/path", func_with_one_arg, reader) - bulk_processor_factory.assert_called_once_with("some/path", func_with_one_arg, reader) - - -def test_run_bulk_process_when_not_given_reader_uses_csv_reader(bulk_processor_factory): - errors.ERRORED = False - run_bulk_process("some/path", func_with_one_arg) - assert type(bulk_processor_factory.call_args[0][2]) == CSVReader + reader = create_mock_reader([1, 2]) + run_bulk_process(func_with_one_arg, reader) + bulk_processor_factory.assert_called_once_with(func_with_one_arg, reader) class TestBulkProcessor(object): - def test_run_when_reader_returns_ordered_dict_process_kwargs(self, mock_open): + def test_run_when_reader_returns_ordered_dict_process_kwargs(self, mock_open, progress_bar): processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - class MockDictReader(object): - def __call__(self, *args, **kwargs): - return [ - OrderedDict({"test1": 1, "test2": 2}), - OrderedDict({"test1": 3, "test2": 4}), - OrderedDict({"test1": 5, "test2": 6}), - ] - - processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) + reader = create_mock_reader( + [ + OrderedDict({"test1": 1, "test2": 2}), + OrderedDict({"test1": 3, "test2": 4}), + OrderedDict({"test1": 5, "test2": 6}), + ] + ) + processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) processor.run() assert (1, 2) in processed_rows assert (3, 4) in processed_rows assert (5, 6) in processed_rows - def test_run_when_reader_returns_dict_process_kwargs(self, mock_open): + def test_run_when_reader_returns_dict_process_kwargs(self, mock_open, progress_bar): processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - class MockDictReader(object): - def __call__(self, *args, **kwargs): - return [ - {"test1": 1, "test2": 2}, - {"test1": 3, "test2": 4}, - {"test1": 5, "test2": 6}, - ] - - processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) + reader = create_mock_reader( + [{"test1": 1, "test2": 2}, {"test1": 3, "test2": 4}, {"test1": 5, "test2": 6}] + ) + processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) processor.run() assert (1, 2) in processed_rows assert (3, 4) in processed_rows assert (5, 6) in processed_rows - def test_run_when_dict_reader_has_none_for_key_ignores_key(self, mock_open): + def test_run_when_dict_reader_has_none_for_key_ignores_key(self, mock_open, progress_bar): processed_rows = [] def func_for_bulk(test1): processed_rows.append(test1) - class MockDictReader(object): - def __call__(self, *args, **kwargs): - return [{"test1": 1, None: 2}] - - processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) + reader = create_mock_reader([{"test1": 1, None: 2}]) + processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) processor.run() assert processed_rows == [1] - def test_run_when_reader_returns_strs_processes_strs(self, mock_open): + def test_run_when_reader_returns_strs_processes_strs(self, mock_open, progress_bar): processed_rows = [] def func_for_bulk(test): processed_rows.append(test) - class MockRowReader(object): - def __call__(self, *args, **kwargs): - return ["row1", "row2", "row3"] - - processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + reader = create_mock_reader(["row1", "row2", "row3"]) + processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) processor.run() assert "row1" in processed_rows assert "row2" in processed_rows @@ -176,17 +164,11 @@ def func_for_bulk(test): if test == "row2": raise Exception() - class MockRowReader(object): - def __call__(self, *args, **kwargs): - return ["row1", "row2", "row3"] - + reader = create_mock_reader(["row1", "row2", "row3"]) with ErrorTrackerTestHelper(): - processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + processor = BulkProcessor(func_for_bulk, reader) processor.run() - with caplog.at_level(logging.INFO): - assert "2 processed successfully out of 3." in caplog.text - with caplog.at_level(logging.ERROR): assert get_view_exceptions_location_message() in caplog.text @@ -194,41 +176,33 @@ def test_run_when_no_errors_occur_prints_success_messages(self, mock_open, caplo def func_for_bulk(test): pass - class MockRowReader(object): - def __call__(self, *args, **kwargs): - return ["row1", "row2", "row3"] - - processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) - + reader = create_mock_reader(["row1", "row2", "row3"]) + processor = BulkProcessor(func_for_bulk, reader) + processor.run() with caplog.at_level(logging.INFO): - processor.run() - assert "3 processed successfully out of 3." in caplog.text + assert "3 succeeded, 0 failed out of 3" in caplog.text - def test_run_when_no_errors_occur_does_not_print_error_message(self, mock_open, caplog): + def test_run_when_no_errors_occur_does_not_print_error_message( + self, mock_open, caplog, progress_bar + ): def func_for_bulk(test): pass - class MockRowReader(object): - def __call__(self, *args, **kwargs): - return ["row1", "row2", "row3"] - - processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + reader = create_mock_reader(["row1", "row2", "row3"]) + processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) with caplog.at_level(logging.ERROR): processor.run() assert get_view_exceptions_location_message() not in caplog.text - def test_run_when_row_is_endline_does_not_process_row(self, mock_open): + def test_run_when_row_is_endline_does_not_process_row(self, mock_open, progress_bar): processed_rows = [] def func_for_bulk(test): processed_rows.append(test) - class MockRowReader(object): - def __call__(self, *args, **kwargs): - return ["row1", "row2", "\n"] - - processor = BulkProcessor("some/path", func_for_bulk, MockRowReader()) + reader = create_mock_reader(["row1", "row2", "\n"]) + processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) processor.run() assert "row1" in processed_rows @@ -236,18 +210,25 @@ def __call__(self, *args, **kwargs): assert "row3" not in processed_rows def test_run_when_reader_returns_dict_rows_containing_empty_strs_converts_them_to_none( - self, mock_open + self, mock_open, progress_bar ): processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - class MockDictReader(object): - def __call__(self, *args, **kwargs): - return [{"test1": "", "test2": "foo"}, {"test1": "bar", "test2": u""}] - - processor = BulkProcessor("some/path", func_for_bulk, MockDictReader()) + reader = create_mock_reader([{"test1": "", "test2": "foo"}, {"test1": "bar", "test2": u""}]) + processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) processor.run() assert (None, "foo") in processed_rows assert ("bar", None) in processed_rows + + def test_run_updates_progress_bar_once_per_row(self, mock_open, progress_bar): + def func_for_bulk(*args, **kwargs): + pass + + rows = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] + reader = create_mock_reader(rows) + processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + processor.run() + assert progress_bar.update.call_count == len(rows) diff --git a/tests/test_logger.py b/tests/test_logger.py index a5809e0d8..27c71cc03 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,3 +1,4 @@ +import pytest import logging import os from logging.handlers import RotatingFileHandler @@ -8,6 +9,8 @@ logger_has_handlers, get_view_exceptions_location_message, RedStderrHandler, + InPlaceStreamHandler, + get_progress_logger, CliLogger, ) from code42cli.util import get_user_project_path @@ -66,6 +69,37 @@ def test_emit_when_info_does_not_alter(self, mocker, caplog): assert actual == "TEST" +class TestInPlaceStreamHandler(object): + def test_emit_when_runtime_recursion_error_occurs_raises_error(self, mocker): + handler = InPlaceStreamHandler() + record = mocker.MagicMock(spec=logging.LogRecord) + + def side_effect(*args, **kwargs): + raise RuntimeError( + "maximum recursion depth exceeded while getting the str of an object" + ) + + handler.format = mocker.MagicMock() + handler.format = side_effect + with pytest.raises(RuntimeError): + handler.emit(record) + + def test_emit_when_non_recursion_error_occurs_calls_handle_error(self, mocker): + handler = InPlaceStreamHandler() + record = mocker.MagicMock(spec=logging.LogRecord) + spy = mocker.spy(handler, "handleError") + + def side_effect(*args, **kwargs): + raise Exception("Bad thing happened") + + handler.format = mocker.MagicMock() + handler.format = side_effect + try: + handler.emit(record) + except Exception: + spy.assert_called_once_with(record) + + class TestCliLogger(object): _logger = CliLogger() diff --git a/tests/test_progress_bar.py b/tests/test_progress_bar.py new file mode 100644 index 000000000..e2e86feee --- /dev/null +++ b/tests/test_progress_bar.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +import logging + +from code42cli.progress_bar import ProgressBar + + +class TestProgressBar(object): + def test_update_when_zero_processed_logs_zero_blocks(self, caplog): + bar = ProgressBar(100) + bar.update(0, "MESSAGE") + with caplog.at_level(logging.INFO): + assert u"█" not in caplog.text + assert "MESSAGE" in caplog.text + + def test_update_logs_one_block_per_processed(self, caplog): + bar = ProgressBar(100) + bar.update(50, "MESSAGE") + with caplog.at_level(logging.INFO): + assert u"█" * 50 in caplog.text + assert u"█" * 51 not in caplog.text + assert "MESSAGE" in caplog.text + + def test_clear_bar_and_print_result_clears_progress_bar(self, caplog): + bar = ProgressBar(100) + bar.clear_bar_and_print_final("MESSAGE") + with caplog.at_level(logging.INFO): + assert u"█" not in caplog.text + assert "MESSAGE" in caplog.text diff --git a/tests/test_worker.py b/tests/test_worker.py index 631bcec3e..7d7df2fd1 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -5,7 +5,7 @@ class TestWorker(object): def test_is_async(self): - worker = Worker(5) + worker = Worker(5, 2) demo_ls = [] def async_func(): From b989cbb9fac2633a239e5cebe525d2076d5612cd Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 21 May 2020 13:00:48 -0700 Subject: [PATCH 061/349] Bugfix/one last flush (#78) --- src/code42cli/bulk.py | 4 +++- src/code42cli/cmds/profile.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 4c8c68de2..949bac8ff 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -1,4 +1,4 @@ -import os, inspect +import os, inspect, sys from code42cli.compat import open, str from code42cli.worker import Worker @@ -93,6 +93,8 @@ def run(self): self._process_row(row) self.__worker.wait() self._print_results() + sys.stdout.flush() + def _process_row(self, row): if isinstance(row, dict): diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index d3c0abad4..a375e417e 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -3,7 +3,7 @@ import code42cli.profile as cliprofile from code42cli.compat import str from code42cli.profile import print_and_log_no_existing_profile -from code42cli.args import PROFILE_HELP, PROFILE_ARG_NAME +from code42cli.args import PROFILE_HELP from code42cli.commands import Command from code42cli.sdk_client import validate_connection from code42cli.util import does_user_agree From 056c2b84a27811166e854400a862d60f0ae3df35 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 21 May 2020 13:23:58 -0700 Subject: [PATCH 062/349] Fix/profile-help-text-consistencies (#79) --- src/code42cli/cmds/profile.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index a375e417e..60f342a0a 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -177,6 +177,9 @@ def _load_optional_profile_description(argument_collection): def _load_profile_create_descriptions(argument_collection): profile = argument_collection.arg_configs[u"name"] profile.set_help(PROFILE_HELP) + profile.add_short_option_name(u"-n") + argument_collection.arg_configs[u"server"].add_short_option_name(u"-s") + argument_collection.arg_configs[u"username"].add_short_option_name(u"-u") _load_profile_settings_descriptions(argument_collection) @@ -194,7 +197,7 @@ def _load_profile_settings_descriptions(argument_collection): server.set_help(u"The url and port of the Code42 server.") username.set_help(u"The username of the Code42 API user.") disable_ssl_errors.set_help( - u"For development purposes, do not validate the SSL certificates of Code42 servers." + u"For development purposes, do not validate the SSL certificates of Code42 servers. " u"This is not recommended unless it is required." ) From 583986ffe8bb30fa57bc81de0e1e0d00a651394c Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 22 May 2020 06:53:05 -0700 Subject: [PATCH 063/349] Prevent negs (#80) --- src/code42cli/worker.py | 3 ++- tests/test_worker.py | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 81d7cb27f..8c6728bb1 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -30,7 +30,8 @@ def total_errors(self): @property def total_successes(self): - return self._total_processed - self._total_errors + val = self._total_processed - self._total_errors + return val if val >= 0 else 0 def __str__(self): return u"{0} succeeded, {1} failed out of {2}".format( diff --git a/tests/test_worker.py b/tests/test_worker.py index 7d7df2fd1..ade911b59 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1,8 +1,15 @@ import time -from code42cli.worker import Worker +from code42cli.worker import Worker, WorkerStats +class TestWorkerStats(object): + def test_successes_when_should_be_negative_returns_zero(self): + stats = WorkerStats(100) + stats._total_errors = 101 + assert not stats.total_successes + + class TestWorker(object): def test_is_async(self): worker = Worker(5, 2) From 16343f04c6e49b5ba0256b0e87d9d3bd95ec2ad6 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 27 May 2020 18:58:18 +0530 Subject: [PATCH 064/349] Feature/fuzzy suggestions (#64) * Added feature to display possible incorrect word in command line * get close matches when command is correct but arguments are misspelled * Refactor and added tests * Do not print help message when incorrect words found * Added tests * Fix tests and format error message * Change in test * Refactor: remove unused import and doc changes * Fix: initialization and docs * Added changelog * Refactor * Fix: Argument flag with underscore while displaying suggestions --- CHANGELOG.md | 2 ++ src/code42cli/invoker.py | 69 ++++++++++++++++++++++++++++++++++++++-- tests/test_invoker.py | 42 ++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f988719..58c34ee46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- Display, `Fuzzy suggestions`, valid keywords matching mistyped commands or arguments. + - `code42 alerts`: - Ability to search/poll for alerts with checkpointing using one of the following commands: - `print` to output to stdout. diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py index d30accb69..70be517eb 100644 --- a/src/code42cli/invoker.py +++ b/src/code42cli/invoker.py @@ -1,5 +1,7 @@ import sys +import difflib + from py42.exceptions import Py42HTTPError, Py42ForbiddenError from code42cli.compat import str @@ -7,8 +9,14 @@ from code42cli.parser import ArgumentParserError, CommandParser from code42cli.logger import get_main_cli_logger +_DIFFLIB_CUT_OFF = 0.7 + class CommandInvoker(object): + + _COMMAND_KEYWORDS = {} + _COMMAND_ARG_KEYWORDS = {} + def __init__(self, top_command, cmd_parser=None): self._top_command = top_command self._cmd_parser = cmd_parser or CommandParser() @@ -72,6 +80,7 @@ def _load_subcommands(self, path, node): for command in node.subcommands: new_key = u"{} {}".format(path, command.name).strip() self._commands[new_key] = command + self._set_command_keywords(new_key) def _try_run_command(self, command, path_parts, input_args): """Runs a command called using `path_parts` by parsing @@ -82,9 +91,65 @@ def _try_run_command(self, command, path_parts, input_args): parser = self._cmd_parser.prepare_cli_help(command) else: parser = self._cmd_parser.prepare_command(command, path_parts) + self._set_argument_keywords(path_parts[0], command.get_arg_configs()) parsed_args = self._cmd_parser.parse_args(input_args) parsed_args.func(parsed_args) except ArgumentParserError as err: - get_main_cli_logger().log_error(err) - parser.print_help(sys.stderr) + logger = get_main_cli_logger() + logger.print_and_log_error(u"{}".format(err)) + possible_correct_words = self._find_incorrect_word_match(err, path_parts) + if possible_correct_words: + logger.print_and_log_error(u"Did you mean one of the following?") + for possible_correct_word in possible_correct_words: + logger.print_info(u" {}".format(possible_correct_word)) + + else: + parser.print_help(sys.stderr) sys.exit(2) + + @staticmethod + def _get_arg_flags(arguments): + flag_names = [] + for arg in arguments.values(): + arg_flags = [name for name in arg.settings["options_list"] if name.startswith("-")] + flag_names.extend(arg_flags) + return flag_names + + def _set_argument_keywords(self, command_key, arguments): + self._COMMAND_ARG_KEYWORDS[command_key] = set() + self._COMMAND_ARG_KEYWORDS[command_key].update(CommandInvoker._get_arg_flags(arguments)) + + def _set_command_keywords(self, new_key): + """Creates a dictionary, with top level command as key and set of all its subcommands + as values. + """ + command_keys = new_key.split() + if len(command_keys) == 1: + self._COMMAND_KEYWORDS[command_keys[0]] = set() + else: + self._COMMAND_KEYWORDS[command_keys[0]].update(command_keys[1:]) + + def _find_incorrect_word_match(self, error, path_parts): + possible_correct_words = [] + + try: + # Here we assume the error string contains ":", for case where it doesn't we + # assume the error is not due to misspelled word and we return error as is. + error_detail, unmatched_words = str(error).split(u":") + except ValueError: + return possible_correct_words + + if not unmatched_words or error_detail != u"unrecognized arguments": + return possible_correct_words + + # Arg-parser sets the first/leftmost incorrect command keyword in the error message. + unmatched_word = unmatched_words.split()[0] + + if not path_parts: + available_values = self._COMMAND_KEYWORDS.keys() + elif unmatched_word.strip().startswith('-'): + available_values = self._COMMAND_ARG_KEYWORDS[path_parts[0]] + else: + available_values = self._COMMAND_KEYWORDS[path_parts[0]] + + return difflib.get_close_matches(unmatched_word, available_values, cutoff=_DIFFLIB_CUT_OFF) diff --git a/tests/test_invoker.py b/tests/test_invoker.py index f7ad174dc..0310774e9 100644 --- a/tests/test_invoker.py +++ b/tests/test_invoker.py @@ -10,6 +10,8 @@ from code42cli.errors import Code42CLIError from code42cli.invoker import CommandInvoker from code42cli.parser import ArgumentParserError, CommandParser +from code42cli.cmds import profile +from code42cli.cmds.securitydata import main as secmain def dummy_method(one, two, three=None): @@ -28,6 +30,19 @@ def load_sub_subcommands(): return [Command("inner1", "the innerdesc1", handler=dummy_method)] +def load_real_sub_commands(): + return [ + Command( + u"profile", u"", subcommand_loader=profile.load_subcommands + ), + Command( + u"security-data", + u"", + subcommand_loader=secmain.load_subcommands, + ) + ] + + @pytest.fixture def mock_parser(mocker): return mocker.MagicMock(spec=CommandParser) @@ -146,3 +161,30 @@ def test_run_when_cli_error_occurs_logs_request(self, mocker, mock_parser, caplo with caplog.at_level(logging.ERROR): invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) assert "a code42cli error" in caplog.text + + def test_run_incorrect_command_suggests_proper_sub_commands(self, caplog): + command = Command(u"", u"", subcommand_loader=load_real_sub_commands) + cmd_invoker = CommandInvoker(command) + with pytest.raises(SystemExit): + cmd_invoker.run([u"profile", u"crate"]) + with caplog.at_level(logging.ERROR): + assert u"Did you mean one of the following?" in caplog.text + assert u"create" in caplog.text + + def test_run_incorrect_command_suggests_proper_main_commands(self, caplog): + command = Command(u"", u"", subcommand_loader=load_real_sub_commands) + cmd_invoker = CommandInvoker(command) + with pytest.raises(SystemExit): + cmd_invoker.run([u"prfile", u"crate"]) + with caplog.at_level(logging.ERROR): + assert u"Did you mean one of the following?" in caplog.text + assert u"profile" in caplog.text + + def test_run_incorrect_command_suggests_proper_argument_name(self, caplog): + command = Command(u"", u"", subcommand_loader=load_real_sub_commands) + cmd_invoker = CommandInvoker(command) + with pytest.raises(SystemExit): + cmd_invoker.run([u"security-data", u"write-to", u"abc", u"--filename"]) + with caplog.at_level(logging.ERROR): + assert u"Did you mean one of the following?" in caplog.text + assert u"--file-name" in caplog.text From 44c62c114ab10d91b2ebd0e065c7229a68826794 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 27 May 2020 19:14:10 +0530 Subject: [PATCH 065/349] Add bulk counterparts for add-risk-tags and remove-risk-tags (#77) * Added bulk feature in hre to add or remove risk-tags * Add bulk generate template for high risk employee * Added tests and rectify inheritance syntax * Added changelog * Docs * Refactor * add extra space * Update CL Co-authored-by: Juliya Smith --- CHANGELOG.md | 5 + src/code42cli/cmds/detectionlists/__init__.py | 76 +++++++-- src/code42cli/cmds/detectionlists/bulk.py | 41 +++++ src/code42cli/cmds/detectionlists/commands.py | 56 ++++++- .../cmds/detectionlists/high_risk_employee.py | 56 +++---- tests/cmds/detectionlists/test_bulk.py | 34 ++++ .../detectionlists/test_high_risk_employee.py | 106 +----------- tests/cmds/detectionlists/test_init.py | 152 +++++++++++++++++- 8 files changed, 370 insertions(+), 156 deletions(-) create mode 100644 src/code42cli/cmds/detectionlists/bulk.py create mode 100644 tests/cmds/detectionlists/test_bulk.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c34ee46..8a0914d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- `code42 high-risk-employee bulk` supports `add-risk-tags` and `remove-risk-tags`. + - `code42 high-risk-employee bulk generate-template ` options `add-risk-tags` and `remove-risk-tags`. + - `add-risk-tags` that takes a csv file with username and space separated risk tags. + - `remove-risk-tags` that takes a csv file with username and space separated risk tags. + - Display, `Fuzzy suggestions`, valid keywords matching mistyped commands or arguments. - `code42 alerts`: diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 6c4a225d9..8ff206a0d 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,10 +1,11 @@ from py42.exceptions import Py42BadRequestError -from code42cli.bulk import generate_template, run_bulk_process, BulkCommandType +from code42cli.bulk import generate_template, run_bulk_process from code42cli.file_readers import create_csv_reader, create_flat_file_reader from code42cli.errors import UserAlreadyAddedError, UserDoesNotExistError, UnknownRiskTagError -from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags +from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory +from code42cli.cmds.detectionlists.bulk import BulkDetectionList, BulkHighRiskEmployee def try_handle_user_already_added_error(bad_request_err, username_tried_adding, list_name): @@ -31,6 +32,9 @@ def __init__(self, add=None, remove=None, load_add=None): self.remove_employee = remove self.load_add_description = load_add + def add_handler(self, attr_name, handler): + self.__setattr__(attr_name, handler) + class DetectionList(object): """An object representing a Code42 detection list. Use this class by passing in handlers for @@ -88,29 +92,49 @@ def load_subcommands(self): return [bulk, add, remove] def _load_bulk_subcommands(self): - generate_template_cmd = self.factory.create_bulk_generate_template_command( - self.generate_template_file - ) + add = self.factory.create_bulk_add_command(self.bulk_add_employees) remove = self.factory.create_bulk_remove_command(self.bulk_remove_employees) - return [generate_template_cmd, add, remove] + commands = [add, remove] + + if self.name == DetectionLists.HIGH_RISK_EMPLOYEE: + commands.extend(self._get_risk_tags_bulk_subcommands()) + else: + generate_template_cmd = self.factory.create_bulk_generate_template_command( + self.generate_template_file + ) + commands.append(generate_template_cmd) + return commands + + def _get_risk_tags_bulk_subcommands(self): + bulk_add_risk_tags = self.factory.create_bulk_add_risk_tags_command(self.bulk_add_risk_tags) + bulk_remove_risk_tags = self.factory.create_bulk_remove_risk_tags_command( + self.bulk_remove_risk_tags + ) + + self.handlers.add_handler(u"add_risk_tags", add_risk_tags) + self.handlers.add_handler(u"remove_risk_tags", remove_risk_tags) + generate_template_cmd = self.factory.create_hre_bulk_generate_template_command( + self.generate_template_file + ) + return [bulk_add_risk_tags, bulk_remove_risk_tags, generate_template_cmd] def generate_template_file(self, cmd, path=None): """Generates a template file a user would need to fill-in for bulk operating on the detection list. - + Args: cmd (str or unicode): An option from the `BulkCommandType` enum specifying which type of file to generate. path (str or unicode, optional): A path to put the file after it's generated. If None, will use the current working directory. Defaults to None. """ - handler = None - if cmd == BulkCommandType.ADD: - handler = self.handlers.add_employee - elif cmd == BulkCommandType.REMOVE: - handler = self.handlers.remove_employee + if self.name == DetectionLists.HIGH_RISK_EMPLOYEE: + detection_list = BulkHighRiskEmployee() + else: + detection_list = BulkDetectionList() + handler = detection_list.get_handler(self.handlers, cmd) generate_template(handler, path) def bulk_add_employees(self, sdk, profile, csv_file): @@ -145,6 +169,14 @@ def _add_employee(self, sdk, profile, **kwargs): def _remove_employee(self, sdk, profile, *args, **kwargs): self.handlers.remove_employee(sdk, profile, *args, **kwargs) + def bulk_add_risk_tags(self, sdk, profile, csv_file): + reader = create_csv_reader(csv_file) + run_bulk_process(lambda **kwargs: add_risk_tags(sdk, profile, **kwargs), reader) + + def bulk_remove_risk_tags(self, sdk, profile, csv_file): + reader = create_csv_reader(csv_file) + run_bulk_process(lambda **kwargs: remove_risk_tags(sdk, profile, **kwargs), reader) + def load_username_description(argument_collection): """Loads the arg descriptions for the `username` CLI parameter.""" @@ -224,3 +256,23 @@ def _try_handle_bad_risk_tag(tags): unknowns = [tag for tag in tags if tag not in options] if tags else None if unknowns: raise UnknownRiskTagError(unknowns) + + +def handle_list_args(list_arg): + """Converts str args to a list. Useful for `bulk` commands which don't use `argparse` but + instead pass in values from files, such as in the form "item1 item2".""" + if list_arg and not isinstance(list_arg, list): + return list_arg.split() + return list_arg + + +def add_risk_tags(sdk, profile, username, tag): + risk_tag = handle_list_args(tag) + user_id = get_user_id(sdk, username) + try_add_risk_tags(sdk, user_id, risk_tag) + + +def remove_risk_tags(sdk, profile, username, tag): + risk_tag = handle_list_args(tag) + user_id = get_user_id(sdk, username) + try_remove_risk_tags(sdk, user_id, risk_tag) diff --git a/src/code42cli/cmds/detectionlists/bulk.py b/src/code42cli/cmds/detectionlists/bulk.py new file mode 100644 index 000000000..3137f558f --- /dev/null +++ b/src/code42cli/cmds/detectionlists/bulk.py @@ -0,0 +1,41 @@ +from code42cli.bulk import BulkCommandType + + +class HighRiskBulkCommandType(BulkCommandType): + ADD_RISK_TAG = u"add-risk-tags" + REMOVE_RISK_TAG = u"remove-risk-tags" + + def __iter__(self): + parent_items = list(super(HighRiskBulkCommandType, self).__iter__()) + return iter([parent_items[0], parent_items[1], self.ADD_RISK_TAG, self.REMOVE_RISK_TAG]) + + +class BulkDetectionList(object): + + def __init__(self): + self.type = BulkCommandType + + def get_handler(self, handlers, cmd): + handler = None + if cmd == self.type.ADD: + handler = handlers.add_employee + elif cmd == self.type.REMOVE: + handler = handlers.remove_employee + return handler + + +class BulkHighRiskEmployee(BulkDetectionList): + + def __init__(self): + super(BulkHighRiskEmployee, self).__init__() + self.type = HighRiskBulkCommandType + + def get_handler(self, handlers, cmd): + handler = super(BulkHighRiskEmployee, self).get_handler(handlers, cmd) + if not handler: + if cmd == self.type.ADD_RISK_TAG: + handler = handlers.add_risk_tags + elif cmd == self.type.REMOVE_RISK_TAG: + handler = handlers.remove_risk_tags + + return handler diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py index 49d4e4171..084b0823d 100644 --- a/src/code42cli/cmds/detectionlists/commands.py +++ b/src/code42cli/cmds/detectionlists/commands.py @@ -1,5 +1,6 @@ from code42cli.bulk import BulkCommandType from code42cli.commands import Command +from code42cli.cmds.detectionlists.bulk import HighRiskBulkCommandType def create_usage_prefix(detection_list_name): @@ -52,6 +53,15 @@ def create_bulk_generate_template_command(self, handler): arg_customizer=DetectionListCommandFactory._load_bulk_generate_template_description, ) + def create_hre_bulk_generate_template_command(self, handler): + return Command( + u"generate-template", + u"Generate the necessary csv template needed for bulk adding users.", + u"{} generate-template ".format(self._bulk_usage_prefix), + handler=handler, + arg_customizer=DetectionListCommandFactory._load_hre_bulk_generate_template_description, + ) + def create_bulk_add_command(self, handler): return Command( BulkCommandType.ADD, @@ -70,12 +80,36 @@ def create_bulk_remove_command(self, handler): arg_customizer=self._load_bulk_remove_description, ) + def create_bulk_add_risk_tags_command(self, handler): + return Command( + u"add-risk-tags", + u"Associates risk tags with a user in bulk.", + u"{} {} ".format(self._bulk_usage_prefix, HighRiskBulkCommandType.ADD_RISK_TAG), + handler=handler, + arg_customizer=self._load_bulk_add_risk_tags_description, + ) + + def create_bulk_remove_risk_tags_command(self, handler): + return Command( + u"remove-risk-tags", + u"Disassociates risk tags from a user in bulk.", + u"{} {} ".format(self._bulk_usage_prefix, HighRiskBulkCommandType.REMOVE_RISK_TAG), + handler=handler, + arg_customizer=self._load_bulk_remove_risk_tags_description, + ) + @staticmethod def _load_bulk_generate_template_description(argument_collection): cmd_type = argument_collection.arg_configs[u"cmd"] - cmd_type.set_help(u"The type of command the template with be used for.") + cmd_type.set_help(u"The type of command the template will be used for.") cmd_type.set_choices(BulkCommandType()) + @staticmethod + def _load_hre_bulk_generate_template_description(argument_collection): + cmd_type = argument_collection.arg_configs[u"cmd"] + cmd_type.set_help(u"The type of command the template will be used for.") + cmd_type.set_choices(HighRiskBulkCommandType()) + def _load_bulk_add_description(self, argument_collection): csv_file = argument_collection.arg_configs[u"csv_file"] csv_file.set_help( @@ -91,3 +125,23 @@ def _load_bulk_remove_description(self, argument_collection): self._name ) ) + + def _load_bulk_add_risk_tags_description(self, argument_collection): + csv_file = argument_collection.arg_configs[u"csv_file"] + csv_file.set_help( + u"A file containing a ',' separated username with space-separated tags to add " + u"to the {} detection list. " + u"e.g. test@email.com,tag1 tag2 tag3".format( + self._name + ) + ) + + def _load_bulk_remove_risk_tags_description(self, argument_collection): + csv_file = argument_collection.arg_configs[u"csv_file"] + csv_file.set_help( + u"A file containing a ',' separated username with space-separated tags to remove " + u"from the {} detection list. " + u"e.g. test@email.com,tag1 tag2 tag3".format( + self._name + ) + ) diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index 5d70a5284..4fe3e4f04 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -1,23 +1,25 @@ +from py42.exceptions import Py42BadRequestError + +from code42cli.commands import Command from code42cli.cmds.detectionlists import ( DetectionList, DetectionListHandlers, load_user_descriptions, - load_username_description, get_user_id, update_user, try_handle_user_already_added_error, - try_add_risk_tags, - try_remove_risk_tags, + add_risk_tags, + remove_risk_tags, + load_username_description, + handle_list_args, ) from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags -from code42cli.commands import Command - -from py42.exceptions import Py42BadRequestError def load_subcommands(): handlers = _create_handlers() detection_list = DetectionList.create_high_risk_employee_list(handlers) + cmd_list = detection_list.load_subcommands() cmd_list.extend( [ @@ -26,14 +28,14 @@ def load_subcommands(): u"Associates risk tags with a user.", u"code42 high-risk-employee add-risk-tags --username --tag ", handler=add_risk_tags, - arg_customizer=_load_risk_tag_mgmt_descriptions, + arg_customizer=load_risk_tag_mgmt_descriptions, ), Command( u"remove-risk-tags", u"Disassociates risk tags from a user.", u"code42 high-risk-employee remove-risk-tags --username --tag ", handler=remove_risk_tags, - arg_customizer=_load_risk_tag_mgmt_descriptions, + arg_customizer=load_risk_tag_mgmt_descriptions, ), ] ) @@ -46,18 +48,6 @@ def _create_handlers(): ) -def add_risk_tags(sdk, profile, username, tag): - risk_tag = _handle_list_args(tag) - user_id = get_user_id(sdk, username) - try_add_risk_tags(sdk, user_id, risk_tag) - - -def remove_risk_tags(sdk, profile, username, tag): - risk_tag = _handle_list_args(tag) - user_id = get_user_id(sdk, username) - try_remove_risk_tags(sdk, user_id, risk_tag) - - def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=None, notes=None): """Adds an employee to the high risk employee detection list. @@ -69,7 +59,7 @@ def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=No risk_tag (iter[str]): Risk tags associated with the employee. notes: (str): Notes about the employee. """ - risk_tag = _handle_list_args(risk_tag) + risk_tag = handle_list_args(risk_tag) user_id = get_user_id(sdk, username) try: @@ -93,7 +83,12 @@ def remove_high_risk_employee(sdk, profile, username): sdk.detectionlists.high_risk_employee.remove(user_id) -def _load_risk_tag_description(argument_collection): +def _load_add_description(argument_collection): + load_user_descriptions(argument_collection) + load_risk_tag_description(argument_collection) + + +def load_risk_tag_description(argument_collection): risk_tag = ( argument_collection.arg_configs.get(DetectionListUserKeys.RISK_TAG) or argument_collection.arg_configs[u"tag"] @@ -105,19 +100,6 @@ def _load_risk_tag_description(argument_collection): ) -def _load_add_description(argument_collection): - load_user_descriptions(argument_collection) - _load_risk_tag_description(argument_collection) - - -def _load_risk_tag_mgmt_descriptions(argument_collection): +def load_risk_tag_mgmt_descriptions(argument_collection): load_username_description(argument_collection) - _load_risk_tag_description(argument_collection) - - -def _handle_list_args(list_arg): - """Converts str args to a list. Useful for `bulk` commands which don't use `argparse` but - instead pass in values from files, such as in the form "item1 item2".""" - if list_arg and type(list_arg) != list: - return list_arg.split() - return list_arg + load_risk_tag_description(argument_collection) diff --git a/tests/cmds/detectionlists/test_bulk.py b/tests/cmds/detectionlists/test_bulk.py new file mode 100644 index 000000000..92704d851 --- /dev/null +++ b/tests/cmds/detectionlists/test_bulk.py @@ -0,0 +1,34 @@ +import pytest +from code42cli.cmds.detectionlists import DetectionListHandlers +from code42cli.bulk import BulkCommandType +from code42cli.cmds.detectionlists.bulk import ( + BulkHighRiskEmployee, BulkDetectionList, HighRiskBulkCommandType +) + + +def test_bulk_risk_command_type_inheritance(): + risk_tags_command_type = HighRiskBulkCommandType() + assert risk_tags_command_type.ADD == BulkCommandType.ADD + assert risk_tags_command_type.ADD_RISK_TAG == u'add-risk-tags' + + +def test_bulk_detection_list_get_handler_returns_valid_handler(): + handlers = DetectionListHandlers( + add=u"x", remove=u"y", load_add=u"z" + ) + bulk_detection_list = BulkDetectionList() + handler = bulk_detection_list.get_handler(handlers, BulkCommandType.ADD) + assert handler == u"x" + + +def test_bulk_high_risk_employee_get_handler_returns_valid_handler(): + handlers = DetectionListHandlers( + add=u"x", remove=u"y", load_add=u"z" + ) + handlers.add_handler(u"add_risk_tags", u"p") + handlers.add_handler(u"remove_risk_tags", u"q") + bulk_hre = BulkHighRiskEmployee() + handler = bulk_hre.get_handler(handlers, HighRiskBulkCommandType.ADD_RISK_TAG) + assert handler == u"p" + handler = bulk_hre.get_handler(handlers, HighRiskBulkCommandType.REMOVE_RISK_TAG) + assert handler == u"q" diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index 76ab69932..b8758cbc5 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -1,13 +1,11 @@ import pytest -import logging from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError, UserDoesNotExistError from code42cli.cmds.detectionlists.high_risk_employee import ( add_high_risk_employee, remove_high_risk_employee, - add_risk_tags, - remove_risk_tags, ) + from code42cli.cmds.detectionlists.enums import RiskTags from .conftest import TEST_ID @@ -107,105 +105,3 @@ def test_remove_high_risk_employee_calls_remove(sdk_with_user, profile): def test_remove_high_risk_employee_when_user_does_not_exist_exits(sdk_without_user, profile): with pytest.raises(UserDoesNotExistError): remove_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) - - -def test_add_risk_tags_adds_tags(sdk_with_user, profile): - add_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] - ) - - -def test_add_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): - add_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - "{} {}".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), - ) - sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] - ) - - -def test_add_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(UserDoesNotExistError): - add_risk_tags( - sdk_without_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - - -def test_add_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( - sdk_with_user, profile, generic_bad_request -): - sdk_with_user.detectionlists.add_user_risk_tags.side_effect = generic_bad_request - try: - add_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - "{} foo {} bar".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), - ) - except UnknownRiskTagError as err: - err_str = str(err) - assert "foo" in err_str - assert "bar" in err_str - - -def test_remove_risk_tags_adds_tags(sdk_with_user, profile): - remove_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( - TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] - ) - - -def test_remove_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): - remove_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - "{} {}".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), - ) - sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( - TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] - ) - - -def test_remove_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(UserDoesNotExistError): - remove_risk_tags( - sdk_without_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - - -def test_remove_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( - sdk_with_user, profile, generic_bad_request -): - sdk_with_user.detectionlists.remove_user_risk_tags.side_effect = generic_bad_request - try: - remove_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - "{} foo {} bar".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), - ) - except UnknownRiskTagError as err: - err_str = str(err) - assert "foo" in err_str - assert "bar" in err_str diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 55aaa6629..85ab0b040 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -1,5 +1,4 @@ import pytest -import logging from code42cli import PRODUCT_NAME from code42cli.cmds.detectionlists import ( @@ -10,15 +9,19 @@ update_user, try_add_risk_tags, try_remove_risk_tags, + add_risk_tags, + remove_risk_tags, ) from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError, UserDoesNotExistError from code42cli.bulk import BulkCommandType from code42cli.cmds.detectionlists.enums import RiskTags +from code42cli.cmds.detectionlists.bulk import HighRiskBulkCommandType from .conftest import TEST_ID from ...conftest import create_mock_reader _NAMESPACE = "{}.cmds.detectionlists".format(PRODUCT_NAME) +_EMPLOYEE = "risky employee" @pytest.fixture @@ -146,3 +149,150 @@ def test_bulk_remove_employees_uses_expected_arguments( detection_list.bulk_remove_employees(sdk, profile, "file_test") assert bulk_processor.call_args[0][1] == reader reader_factory.assert_called_once_with("file_test") + + def test_bulk_add_risk_tags_uses_csv_path(self, mocker, sdk, profile, bulk_processor): + reader = create_mock_reader([{"test": "value"}]) + reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) + reader_factory.return_value = reader + detection_list = DetectionList("TestList", DetectionListHandlers()) + detection_list.bulk_add_risk_tags(sdk, profile, "csv_test") + assert bulk_processor.call_args[0][1] == reader + reader_factory.assert_called_once_with("csv_test") + + def test_bulk_remove_risk_tags_uses_csv_path(self, mocker, sdk, profile, bulk_processor): + reader = create_mock_reader([{"test": "value"}]) + reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) + reader_factory.return_value = reader + detection_list = DetectionList("TestList", DetectionListHandlers()) + detection_list.bulk_remove_risk_tags(sdk, profile, "file_test") + assert bulk_processor.call_args[0][1] == reader + reader_factory.assert_called_once_with("file_test") + + def test_generate_template_file_when_given_add_risk_tags_generates_template_from_handler( + self, bulk_template_generator + ): + def a_test_func(): + pass + + handlers = DetectionListHandlers() + handlers.add_handler("add_risk_tags", a_test_func) + detection_list = DetectionList.create_high_risk_employee_list(handlers) + path = "some/path" + detection_list.generate_template_file(HighRiskBulkCommandType.ADD_RISK_TAG, path) + bulk_template_generator.assert_called_once_with(a_test_func, path) + + def test_generate_template_file_when_given_remove_risk_tags_generates_template_from_handler( + self, bulk_template_generator + ): + def a_test_func(): + pass + + handlers = DetectionListHandlers() + handlers.add_handler("remove_risk_tags", a_test_func) + detection_list = DetectionList.create_high_risk_employee_list(handlers) + path = "some/path" + detection_list.generate_template_file(HighRiskBulkCommandType.REMOVE_RISK_TAG, path) + bulk_template_generator.assert_called_once_with(a_test_func, path) + + +def test_add_risk_tags_adds_tags(sdk_with_user, profile): + add_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], + ) + sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( + TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] + ) + + +def test_add_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): + add_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + "{} {}".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), + ) + sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( + TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] + ) + + +def test_add_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): + with pytest.raises(UserDoesNotExistError): + add_risk_tags( + sdk_without_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], + ) + + +def test_add_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( + sdk_with_user, profile, generic_bad_request +): + sdk_with_user.detectionlists.add_user_risk_tags.side_effect = generic_bad_request + try: + add_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + "{} foo {} bar".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), + ) + except UnknownRiskTagError as err: + err_str = str(err) + assert "foo" in err_str + assert "bar" in err_str + + +def test_remove_risk_tags_adds_tags(sdk_with_user, profile): + remove_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], + ) + sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( + TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] + ) + + +def test_remove_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): + remove_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + "{} {}".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), + ) + sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( + TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] + ) + + +def test_remove_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): + with pytest.raises(UserDoesNotExistError): + remove_risk_tags( + sdk_without_user, + profile, + _EMPLOYEE, + [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], + ) + + +def test_remove_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( + sdk_with_user, profile, generic_bad_request +): + sdk_with_user.detectionlists.remove_user_risk_tags.side_effect = generic_bad_request + try: + remove_risk_tags( + sdk_with_user, + profile, + _EMPLOYEE, + "{} foo {} bar".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), + ) + except UnknownRiskTagError as err: + err_str = str(err) + assert "foo" in err_str + assert "bar" in err_str + From d9d5ebb086cf9037eaf647db8ebf76c077df8b7e Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 27 May 2020 09:56:22 -0500 Subject: [PATCH 066/349] Feature/autocomplete (#75) --- CONTRIBUTING.md | 8 +- README.md | 16 +++ bin/code42cli_completer | 17 +++ setup.py | 1 + src/code42cli/__init__.py | 1 + src/code42cli/bulk.py | 4 +- src/code42cli/cmds/alerts/main.py | 86 +++++------ src/code42cli/cmds/alerts/rules/commands.py | 47 ++++--- src/code42cli/cmds/detectionlists/__init__.py | 32 +++-- src/code42cli/cmds/detectionlists/bulk.py | 4 +- src/code42cli/cmds/detectionlists/commands.py | 63 ++++++--- .../cmds/detectionlists/departing_employee.py | 14 +- .../cmds/detectionlists/high_risk_employee.py | 53 +++---- src/code42cli/cmds/profile.py | 133 ++++++++++-------- src/code42cli/cmds/securitydata/main.py | 90 ++++++------ src/code42cli/commands.py | 43 +++++- src/code42cli/completer.py | 56 ++++++++ src/code42cli/invoker.py | 2 +- src/code42cli/main.py | 106 +++++++++----- src/code42cli/parser.py | 2 +- src/code42cli/progress_bar.py | 2 +- tests/cmds/detectionlists/test_bulk.py | 14 +- tests/cmds/detectionlists/test_init.py | 3 +- tests/test_bulk.py | 18 +-- tests/test_commands.py | 38 ++++- tests/test_completer.py | 86 +++++++++++ tests/test_invoker.py | 66 ++++----- tests/test_parser.py | 11 +- tests/test_worker.py | 2 +- 29 files changed, 674 insertions(+), 344 deletions(-) create mode 100755 bin/code42cli_completer create mode 100644 src/code42cli/completer.py create mode 100644 tests/test_completer.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbd6dcfd8..f53acf657 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,18 +81,18 @@ def test_add_one_and_one_equals_two(): See class documentation on the [Command](src/code42cli/commands.py) class for an explanation of its constructor parameters. 1. If you are creating a new top-level command, create a new instance of `Command` and add it to the list returned - by `_load_top_commands()` function in `code42cli.main`. + by `load_commands()` function in `code42cli.main.MainSubcommandLoader`. 2. If you are creating a new subcommand, find the top-level command that this will be a subcommand of in - `_load_top_commands()` in `code42cli.main` and navigate to the function assigned to be its subcommand loader. - Then, add a new instance of `Command` to the list returned by that function. + `load_commands()` in `code42cli.main.MainSubcommandLoader` and navigate to its subcommand loader's `load_commands()` + Then, add a new instance of `Command` to the list returned. 3. For commands that actually are executed (rather than just being groups), you will add a `handler` function as a constructor parameter. This will be the function that you want to execute when your command is run. * _Positional_ arguments of the handler will automatically become _required_ cli arguments. * The order that the positional arguments should be entered in on the cli is the same as the order in which they appear in the handler. * _Keyword_ arguments of the handler will automatically become _optional_ cli arguments - * the cli argument name will be the same as the handler param name except with `_` replaced with `-`, and prefixed with `--` if optional + * the cli argument name will be the same as the handler param name except with `_` replaced with `-`, and prefixed with `--` if optional. For example, consider the following python function: diff --git a/README.md b/README.md index 297bb1cd8..cfc144b67 100644 --- a/README.md +++ b/README.md @@ -203,3 +203,19 @@ reported. If you keep getting prompted for your password, try resetting with `code42 profile reset-pw`. If that doesn't work, delete your credentials file located at ~/.code42cli or the entry in keychain. + +## Tab completion + +For `zsh`, add these commands to your `.zshrc` file: + +```bash +C42_COMPLETER=$(which code42cli_completer) +autoload bashcompinit && bashcompinit +complete -C '$C42_COMPLETER' code42 +``` + +For bash, add just the first and last commands to your `.bash_profile`: +```bash +C42_COMPLETER=$(which code42cli_completer) +complete -C '$C42_COMPLETER' code42 +``` diff --git a/bin/code42cli_completer b/bin/code42cli_completer new file mode 100755 index 000000000..d893c11f3 --- /dev/null +++ b/bin/code42cli_completer @@ -0,0 +1,17 @@ +# file inspired from awscli https://github.com/aws/aws-cli/blob/develop/bin/aws_completer + +import os +if os.environ.get('LC_CTYPE', '') == 'UTF-8': + os.environ['LC_CTYPE'] = 'en_US.UTF-8' +import code42cli.completer + +if __name__ == '__main__': + # bash exports COMP_LINE and COMP_POINT, tcsh COMMAND_LINE only + cline = os.environ.get('COMP_LINE') or os.environ.get('COMMAND_LINE') or '' + cpoint = int(os.environ.get('COMP_POINT') or len(cline)) + try: + code42cli.completer.complete(cline, cpoint) + except KeyboardInterrupt: + # If the user hits Ctrl+C, we don't want to print + # a trace + pass diff --git a/setup.py b/setup.py index 348ceeaa8..bf25f1036 100644 --- a/setup.py +++ b/setup.py @@ -52,5 +52,6 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", ], + scripts=["bin/code42cli_completer"], entry_points={"console_scripts": ["code42=code42cli.main:main"]}, ) diff --git a/src/code42cli/__init__.py b/src/code42cli/__init__.py index c361115bd..ff01da097 100644 --- a/src/code42cli/__init__.py +++ b/src/code42cli/__init__.py @@ -1 +1,2 @@ PRODUCT_NAME = u"code42cli" +MAIN_COMMAND = u"code42" diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 949bac8ff..4c8c68de2 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -1,4 +1,4 @@ -import os, inspect, sys +import os, inspect from code42cli.compat import open, str from code42cli.worker import Worker @@ -93,8 +93,6 @@ def run(self): self._process_row(row) self.__worker.wait() self._print_results() - sys.stdout.flush() - def _process_row(self, row): if isinstance(row, dict): diff --git a/src/code42cli/cmds/alerts/main.py b/src/code42cli/cmds/alerts/main.py index 3cc8c6f9c..6c9cafb50 100644 --- a/src/code42cli/cmds/alerts/main.py +++ b/src/code42cli/cmds/alerts/main.py @@ -1,5 +1,5 @@ from code42cli.args import ArgConfig -from code42cli.commands import Command +from code42cli.commands import Command, SubcommandLoader from code42cli.cmds.alerts.extraction import extract from code42cli.cmds.search_shared import args, logger_factory from code42cli.cmds.search_shared.enums import ( @@ -12,45 +12,51 @@ from code42cli.cmds.search_shared.cursor_store import AlertCursorStore -def load_subcommands(): - """Sets up the `alerts` subcommand with all of its subcommands.""" - usage_prefix = u"code42 alerts" - - print_func = Command( - u"print", - u"Print alerts to stdout", - u"{} {}".format(usage_prefix, u"print "), - handler=print_out, - arg_customizer=_load_search_args, - use_single_arg_obj=True, - ) - - write = Command( - u"write-to", - u"Write alerts to the file with the given name.", - u"{} {}".format(usage_prefix, u"write-to "), - handler=write_to, - arg_customizer=_load_write_to_args, - use_single_arg_obj=True, - ) - - send = Command( - u"send-to", - u"Send alerts to the given server address.", - u"{} {}".format(usage_prefix, u"send-to "), - handler=send_to, - arg_customizer=_load_send_to_args, - use_single_arg_obj=True, - ) - - clear = Command( - u"clear-checkpoint", - u"Remove the saved alert checkpoint from 'incremental' (-i) mode.", - u"{} {}".format(usage_prefix, u"clear-checkpoint "), - handler=clear_checkpoint, - ) - - return [print_func, write, send, clear] +class MainAlertsSubcommandLoader(SubcommandLoader): + PRINT = u"print" + WRITE_TO = u"write-to" + SEND_TO = u"send-to" + CLEAR_CHECKPOINT = u"clear-checkpoint" + + def load_commands(self): + """Sets up the `alerts` subcommand with all of its subcommands.""" + usage_prefix = u"code42 alerts" + + print_func = Command( + self.PRINT, + u"Print alerts to stdout", + u"{} {}".format(usage_prefix, u"print "), + handler=print_out, + arg_customizer=_load_search_args, + use_single_arg_obj=True, + ) + + write = Command( + self.WRITE_TO, + u"Write alerts to the file with the given name.", + u"{} {}".format(usage_prefix, u"write-to "), + handler=write_to, + arg_customizer=_load_write_to_args, + use_single_arg_obj=True, + ) + + send = Command( + self.SEND_TO, + u"Send alerts to the given server address.", + u"{} {}".format(usage_prefix, u"send-to "), + handler=send_to, + arg_customizer=_load_send_to_args, + use_single_arg_obj=True, + ) + + clear = Command( + self.CLEAR_CHECKPOINT, + u"Remove the saved alert checkpoint from 'incremental' (-i) mode.", + u"{} {}".format(usage_prefix, u"clear-checkpoint "), + handler=clear_checkpoint, + ) + + return [print_func, write, send, clear] def clear_checkpoint(sdk, profile): diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py index a7b469cde..0bf3976e7 100644 --- a/src/code42cli/cmds/alerts/rules/commands.py +++ b/src/code42cli/cmds/alerts/rules/commands.py @@ -1,4 +1,5 @@ -from code42cli.commands import Command +from code42cli import MAIN_COMMAND +from code42cli.commands import Command, SubcommandLoader from code42cli.bulk import generate_template, BulkCommandType from code42cli.cmds.alerts.rules.user_rule import ( add_user, @@ -61,13 +62,16 @@ def _load_bulk_generate_template_description(argument_collection): cmd_type.set_choices(BulkCommandType()) -class AlertRulesBulkCommands(object): - @staticmethod - def load_commands(): - usage_prefix = u"code42 alert-rules bulk" +class AlertRulesBulkSubcommandLoader(SubcommandLoader): + GENERATE_TEMPLATE = u"generate-template" + ADD = u"add" + REMOVE = u"remove" + + def load_commands(self): + usage_prefix = u"{} alert-rules bulk".format(MAIN_COMMAND) generate_template_cmd = Command( - u"generate-template", + self.GENERATE_TEMPLATE, u"Generate the necessary csv template needed for bulk adding users.", u"{} generate-template ".format(usage_prefix), handler=_generate_template_file, @@ -75,7 +79,7 @@ def load_commands(): ) bulk_add = Command( - u"add", + self.ADD, u"Update alert rule criteria to add users and all their aliases. " u"CSV file format: rule_id,username", u"{} add ".format(usage_prefix), @@ -84,7 +88,7 @@ def load_commands(): ) bulk_remove = Command( - u"remove", + self.REMOVE, u"Update alert rule criteria to remove users and all their aliases. " u"CSV file format: rule_id,username", u"{} remove ".format(usage_prefix), @@ -95,13 +99,22 @@ def load_commands(): return [generate_template_cmd, bulk_add, bulk_remove] -class AlertRulesCommands(object): - @staticmethod - def load_subcommands(): +class AlertRulesSubcommandLoader(SubcommandLoader): + ADD_USER = u"add-user" + REMOVE_USER = u"remove-user" + LIST = u"list" + SHOW = u"show" + BULK = u"bulk" + + def __init__(self, root_command_name): + super(AlertRulesSubcommandLoader, self).__init__(root_command_name) + self._bulk_subcommand_loader = AlertRulesBulkSubcommandLoader(self.BULK) + + def load_commands(self): usage_prefix = u"code42 alert-rules" add = Command( - u"add-user", + self.ADD_USER, u"Update alert rule criteria to monitor user aliases against the given username.", u"{} add-user --rule-id --username ".format(usage_prefix), handler=add_user, @@ -109,7 +122,7 @@ def load_subcommands(): ) remove = Command( - u"remove-user", + self.REMOVE_USER, u"Update alert rule criteria to remove a user and all their aliases.", u"{} remove-user --rule-id --username ".format(usage_prefix), handler=remove_user, @@ -117,14 +130,14 @@ def load_subcommands(): ) list_rules = Command( - u"list", + self.LIST, u"Fetch existing alert rules.", u"{} list".format(usage_prefix), handler=get_rules, ) show = Command( - u"show", + self.SHOW, u"Fetch configured alert-rules against the rule ID.", u"{} show ".format(usage_prefix), handler=show_rule, @@ -132,9 +145,9 @@ def load_subcommands(): ) bulk = Command( - u"bulk", + self.BULK, u"Tools for executing bulk commands.", - subcommand_loader=AlertRulesBulkCommands.load_commands, + subcommand_loader=self._bulk_subcommand_loader, ) return [add, remove, list_rules, show, bulk] diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 8ff206a0d..f8e360eab 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,10 +1,10 @@ from py42.exceptions import Py42BadRequestError +from code42cli.cmds.detectionlists.commands import DetectionListSubcommandLoader from code42cli.bulk import generate_template, run_bulk_process from code42cli.file_readers import create_csv_reader, create_flat_file_reader from code42cli.errors import UserAlreadyAddedError, UserDoesNotExistError, UnknownRiskTagError from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags -from code42cli.cmds.detectionlists.commands import DetectionListCommandFactory from code42cli.cmds.detectionlists.bulk import BulkDetectionList, BulkHighRiskEmployee @@ -46,13 +46,15 @@ class DetectionList(object): given `classmethods`. handlers (DetectionListHandlers): A DTO containing implementations for adding / removing users from specific lists. - cmd_factory (DetectionListCommandFactory): A factory that creates detection list commands. + cmd_factory (DetectionListSubcommandLoader): A factory that creates detection list commands. """ - def __init__(self, list_name, handlers, cmd_factory=None): + def __init__(self, list_name, handlers, subcommand_loader=None): self.name = list_name self.handlers = handlers - self.factory = cmd_factory or DetectionListCommandFactory(list_name) + self.subcommand_loader = subcommand_loader or DetectionListSubcommandLoader(list_name) + self.bulk_subcommand_loader = self.subcommand_loader.bulk_subcommand_loader + self.bulk_subcommand_loader.load_commands = lambda: self._load_bulk_subcommands @classmethod def create_high_risk_employee_list(cls, handlers): @@ -82,39 +84,41 @@ def create_departing_employee_list(cls, handlers): def load_subcommands(self): """Loads high risk employee related subcommands""" - bulk = self.factory.create_bulk_command(lambda: self._load_bulk_subcommands()) - add = self.factory.create_add_command( + bulk = self.subcommand_loader.create_bulk_command() + bulk.subcommand_loader.load_commands = lambda: self._load_bulk_subcommands() + add = self.subcommand_loader.create_add_command( self.handlers.add_employee, self.handlers.load_add_description ) - remove = self.factory.create_remove_command( + remove = self.subcommand_loader.create_remove_command( self.handlers.remove_employee, load_username_description ) return [bulk, add, remove] def _load_bulk_subcommands(self): - - add = self.factory.create_bulk_add_command(self.bulk_add_employees) - remove = self.factory.create_bulk_remove_command(self.bulk_remove_employees) + add = self.bulk_subcommand_loader.create_bulk_add_command(self.bulk_add_employees) + remove = self.bulk_subcommand_loader.create_bulk_remove_command(self.bulk_remove_employees) commands = [add, remove] if self.name == DetectionLists.HIGH_RISK_EMPLOYEE: commands.extend(self._get_risk_tags_bulk_subcommands()) else: - generate_template_cmd = self.factory.create_bulk_generate_template_command( + generate_template_cmd = self.bulk_subcommand_loader.create_bulk_generate_template_command( self.generate_template_file ) commands.append(generate_template_cmd) return commands def _get_risk_tags_bulk_subcommands(self): - bulk_add_risk_tags = self.factory.create_bulk_add_risk_tags_command(self.bulk_add_risk_tags) - bulk_remove_risk_tags = self.factory.create_bulk_remove_risk_tags_command( + bulk_add_risk_tags = self.bulk_subcommand_loader.create_bulk_add_risk_tags_command( + self.bulk_add_risk_tags + ) + bulk_remove_risk_tags = self.bulk_subcommand_loader.create_bulk_remove_risk_tags_command( self.bulk_remove_risk_tags ) self.handlers.add_handler(u"add_risk_tags", add_risk_tags) self.handlers.add_handler(u"remove_risk_tags", remove_risk_tags) - generate_template_cmd = self.factory.create_hre_bulk_generate_template_command( + generate_template_cmd = self.bulk_subcommand_loader.create_hre_bulk_generate_template_command( self.generate_template_file ) return [bulk_add_risk_tags, bulk_remove_risk_tags, generate_template_cmd] diff --git a/src/code42cli/cmds/detectionlists/bulk.py b/src/code42cli/cmds/detectionlists/bulk.py index 3137f558f..3867d8b62 100644 --- a/src/code42cli/cmds/detectionlists/bulk.py +++ b/src/code42cli/cmds/detectionlists/bulk.py @@ -11,10 +11,9 @@ def __iter__(self): class BulkDetectionList(object): - def __init__(self): self.type = BulkCommandType - + def get_handler(self, handlers, cmd): handler = None if cmd == self.type.ADD: @@ -25,7 +24,6 @@ def get_handler(self, handlers, cmd): class BulkHighRiskEmployee(BulkDetectionList): - def __init__(self): super(BulkHighRiskEmployee, self).__init__() self.type = HighRiskBulkCommandType diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py index 084b0823d..c744248d9 100644 --- a/src/code42cli/cmds/detectionlists/commands.py +++ b/src/code42cli/cmds/detectionlists/commands.py @@ -1,5 +1,5 @@ from code42cli.bulk import BulkCommandType -from code42cli.commands import Command +from code42cli.commands import Command, SubcommandLoader from code42cli.cmds.detectionlists.bulk import HighRiskBulkCommandType @@ -11,24 +11,36 @@ def create_bulk_usage_prefix(detection_list_name): return u"{} bulk".format(create_usage_prefix(detection_list_name)) -class DetectionListCommandFactory: +def _load_bulk_generate_template_description(argument_collection): + cmd_type = argument_collection.arg_configs[u"cmd"] + cmd_type.set_help(u"The type of command the template with be used for.") + cmd_type.set_choices(BulkCommandType()) + + +class DetectionListSubcommandLoader(SubcommandLoader): + BULK = u"bulk" + ADD = BulkCommandType.ADD + REMOVE = BulkCommandType.REMOVE _USAGE_SUFFIX = u" " - def __init__(self, detection_list_name): + def __init__(self, detection_list_name, bulk_subcommand_loader=None): + super(DetectionListSubcommandLoader, self).__init__(detection_list_name) self._name = detection_list_name self._usage_prefix = create_usage_prefix(detection_list_name) - self._bulk_usage_prefix = create_bulk_usage_prefix(detection_list_name) + self.bulk_subcommand_loader = bulk_subcommand_loader or DetectionListBulkSubcommandLoader( + self.BULK, detection_list_name + ) - def create_bulk_command(self, subcommand_loader): + def create_bulk_command(self): return Command( - u"bulk", + self.BULK, u"Tools for executing bulk {} commands.".format(self._name), - subcommand_loader=subcommand_loader, + subcommand_loader=self.bulk_subcommand_loader, ) def create_add_command(self, handler, arg_customizer): return Command( - BulkCommandType.ADD, + self.ADD, u"Add a user to the {} detection list.".format(self._name), u"{} {} {}".format(self._usage_prefix, BulkCommandType.ADD, self._USAGE_SUFFIX), handler=handler, @@ -37,20 +49,31 @@ def create_add_command(self, handler, arg_customizer): def create_remove_command(self, handler, arg_customizer): return Command( - BulkCommandType.REMOVE, + self.REMOVE, u"Remove a user from the {} detection list.".format(self._name), u"{} {} {}".format(self._usage_prefix, BulkCommandType.REMOVE, self._USAGE_SUFFIX), handler=handler, arg_customizer=arg_customizer, ) + +class DetectionListBulkSubcommandLoader(SubcommandLoader): + ADD = BulkCommandType.ADD + REMOVE = BulkCommandType.REMOVE + GENERATE_TEMPLATE = u"generate-template" + + def __init__(self, root_command_name, detection_list_name): + super(DetectionListBulkSubcommandLoader, self).__init__(root_command_name) + self._bulk_usage_prefix = create_bulk_usage_prefix(detection_list_name) + self._name = detection_list_name + def create_bulk_generate_template_command(self, handler): return Command( - u"generate-template", + self.GENERATE_TEMPLATE, u"Generate the necessary csv template needed for bulk adding users.", u"{} generate-template ".format(self._bulk_usage_prefix), handler=handler, - arg_customizer=DetectionListCommandFactory._load_bulk_generate_template_description, + arg_customizer=_load_bulk_generate_template_description, ) def create_hre_bulk_generate_template_command(self, handler): @@ -59,12 +82,12 @@ def create_hre_bulk_generate_template_command(self, handler): u"Generate the necessary csv template needed for bulk adding users.", u"{} generate-template ".format(self._bulk_usage_prefix), handler=handler, - arg_customizer=DetectionListCommandFactory._load_hre_bulk_generate_template_description, + arg_customizer=self._load_hre_bulk_generate_template_description, ) def create_bulk_add_command(self, handler): return Command( - BulkCommandType.ADD, + self.ADD, u"Bulk add users to the {} detection list using a csv file.".format(self._name), u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.ADD), handler=handler, @@ -73,7 +96,7 @@ def create_bulk_add_command(self, handler): def create_bulk_remove_command(self, handler): return Command( - BulkCommandType.REMOVE, + self.REMOVE, u"Bulk remove users from the {} detection list using a file.".format(self._name), u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.REMOVE), handler=handler, @@ -93,7 +116,9 @@ def create_bulk_remove_risk_tags_command(self, handler): return Command( u"remove-risk-tags", u"Disassociates risk tags from a user in bulk.", - u"{} {} ".format(self._bulk_usage_prefix, HighRiskBulkCommandType.REMOVE_RISK_TAG), + u"{} {} ".format( + self._bulk_usage_prefix, HighRiskBulkCommandType.REMOVE_RISK_TAG + ), handler=handler, arg_customizer=self._load_bulk_remove_risk_tags_description, ) @@ -131,9 +156,7 @@ def _load_bulk_add_risk_tags_description(self, argument_collection): csv_file.set_help( u"A file containing a ',' separated username with space-separated tags to add " u"to the {} detection list. " - u"e.g. test@email.com,tag1 tag2 tag3".format( - self._name - ) + u"e.g. test@email.com,tag1 tag2 tag3".format(self._name) ) def _load_bulk_remove_risk_tags_description(self, argument_collection): @@ -141,7 +164,5 @@ def _load_bulk_remove_risk_tags_description(self, argument_collection): csv_file.set_help( u"A file containing a ',' separated username with space-separated tags to remove " u"from the {} detection list. " - u"e.g. test@email.com,tag1 tag2 tag3".format( - self._name - ) + u"e.g. test@email.com,tag1 tag2 tag3".format(self._name) ) diff --git a/src/code42cli/cmds/detectionlists/departing_employee.py b/src/code42cli/cmds/detectionlists/departing_employee.py index 5224105f4..9a9756343 100644 --- a/src/code42cli/cmds/detectionlists/departing_employee.py +++ b/src/code42cli/cmds/detectionlists/departing_employee.py @@ -5,16 +5,22 @@ get_user_id, update_user, try_handle_user_already_added_error, + DetectionListSubcommandLoader, ) from code42cli.cmds.detectionlists.enums import DetectionLists from py42.exceptions import Py42BadRequestError -def load_subcommands(): - handlers = _create_handlers() - detection_list = DetectionList.create_departing_employee_list(handlers) - return detection_list.load_subcommands() +class DepartingEmployeeSubcommandLoader(DetectionListSubcommandLoader): + def __init__(self, root_command_name): + super(DepartingEmployeeSubcommandLoader, self).__init__(root_command_name) + handlers = _create_handlers() + self.detection_list = DetectionList.create_departing_employee_list(handlers) + self._cmd_loader = self.detection_list.subcommand_loader + + def load_commands(self): + return self.detection_list.load_subcommands() def _create_handlers(): diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index 4fe3e4f04..ad8afc83e 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -1,5 +1,6 @@ from py42.exceptions import Py42BadRequestError +from code42cli.cmds.detectionlists import DetectionListSubcommandLoader from code42cli.commands import Command from code42cli.cmds.detectionlists import ( DetectionList, @@ -16,30 +17,34 @@ from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags -def load_subcommands(): - handlers = _create_handlers() - detection_list = DetectionList.create_high_risk_employee_list(handlers) - - cmd_list = detection_list.load_subcommands() - cmd_list.extend( - [ - Command( - u"add-risk-tags", - u"Associates risk tags with a user.", - u"code42 high-risk-employee add-risk-tags --username --tag ", - handler=add_risk_tags, - arg_customizer=load_risk_tag_mgmt_descriptions, - ), - Command( - u"remove-risk-tags", - u"Disassociates risk tags from a user.", - u"code42 high-risk-employee remove-risk-tags --username --tag ", - handler=remove_risk_tags, - arg_customizer=load_risk_tag_mgmt_descriptions, - ), - ] - ) - return cmd_list +class HighRiskEmployeeSubcommandLoader(DetectionListSubcommandLoader): + def __init__(self, root_command_name): + super(HighRiskEmployeeSubcommandLoader, self).__init__(root_command_name) + handlers = _create_handlers() + self.detection_list = DetectionList.create_departing_employee_list(handlers) + self._cmd_loader = self.detection_list.subcommand_loader + + def load_commands(self): + cmds = self.detection_list.load_subcommands() + cmds.extend( + [ + Command( + u"add-risk-tags", + u"Associates risk tags with a user.", + u"code42 high-risk-employee add-risk-tags --username --tag ", + handler=add_risk_tags, + arg_customizer=load_risk_tag_mgmt_descriptions, + ), + Command( + u"remove-risk-tags", + u"Disassociates risk tags from a user.", + u"code42 high-risk-employee remove-risk-tags --username --tag ", + handler=remove_risk_tags, + arg_customizer=load_risk_tag_mgmt_descriptions, + ), + ] + ) + return cmds def _create_handlers(): diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 60f342a0a..193449823 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -1,83 +1,94 @@ from getpass import getpass +from code42cli import MAIN_COMMAND import code42cli.profile as cliprofile from code42cli.compat import str from code42cli.profile import print_and_log_no_existing_profile from code42cli.args import PROFILE_HELP -from code42cli.commands import Command +from code42cli.commands import Command, SubcommandLoader from code42cli.sdk_client import validate_connection from code42cli.util import does_user_agree from code42cli.logger import get_main_cli_logger -def load_subcommands(): - """Sets up the `profile` subcommand with all of its subcommands.""" - usage_prefix = u"code42 profile" - - show = Command( - u"show", - u"Print the details of a profile.", - u"{} {}".format(usage_prefix, u"show "), - handler=show_profile, - arg_customizer=_load_optional_profile_description, - ) +class ProfileSubcommandLoader(SubcommandLoader): + SHOW = u"show" + LIST = u"list" + USE = u"use" + RESET_PW = u"reset-pw" + CREATE = u"create" + UPDATE = u"update" + DELETE = u"delete" + DELETE_ALL = u"delete-all" + + def load_commands(self): + """Sets up the `profile` subcommand with all of its subcommands.""" + usage_prefix = u"{} profile".format(MAIN_COMMAND) + + show = Command( + self.SHOW, + u"Print the details of a profile.", + u"{} {}".format(usage_prefix, u"show "), + handler=show_profile, + arg_customizer=_load_optional_profile_description, + ) - list_all = Command( - u"list", - u"Show all existing stored profiles.", - u"{} {}".format(usage_prefix, u"list"), - handler=list_profiles, - ) + list_all = Command( + self.LIST, + u"Show all existing stored profiles.", + u"{} {}".format(usage_prefix, u"list"), + handler=list_profiles, + ) - use = Command( - u"use", - u"Set a profile as the default.", - u"{} {}".format(usage_prefix, u"use "), - handler=use_profile, - ) + use = Command( + self.USE, + u"Set a profile as the default.", + u"{} {}".format(usage_prefix, u"use "), + handler=use_profile, + ) - reset_pw = Command( - u"reset-pw", - u"Change the stored password for a profile.", - u"{} {}".format(usage_prefix, u"reset-pw "), - handler=prompt_for_password_reset, - arg_customizer=_load_optional_profile_description, - ) + reset_pw = Command( + self.RESET_PW, + u"Change the stored password for a profile.", + u"{} {}".format(usage_prefix, u"reset-pw "), + handler=prompt_for_password_reset, + arg_customizer=_load_optional_profile_description, + ) - create = Command( - u"create", - u"Create profile settings. The first profile created will be the default.", - u"{} {}".format( - usage_prefix, - u"create --name --server --username ", - ), - handler=create_profile, - arg_customizer=_load_profile_create_descriptions, - ) + create = Command( + self.CREATE, + u"Create profile settings. The first profile created will be the default.", + u"{} {}".format( + usage_prefix, + u"create --name --server --username ", + ), + handler=create_profile, + arg_customizer=_load_profile_create_descriptions, + ) - update = Command( - u"update", - u"Update an existing profile.", - u"{} {}".format(usage_prefix, u"update "), - handler=update_profile, - arg_customizer=_load_profile_update_descriptions, - ) + update = Command( + self.UPDATE, + u"Update an existing profile.", + u"{} {}".format(usage_prefix, u"update "), + handler=update_profile, + arg_customizer=_load_profile_update_descriptions, + ) - delete = Command( - u"delete", - u"Deletes a profile and its stored password (if any).", - u"{} {}".format(usage_prefix, u"delete "), - handler=delete_profile, - ) + delete = Command( + self.DELETE, + u"Deletes a profile and its stored password (if any).", + u"{} {}".format(usage_prefix, u"delete "), + handler=delete_profile, + ) - delete_all = Command( - u"delete-all", - u"Deletes all profiles and saved passwords (if any).", - u"{} {}".format(usage_prefix, u"delete-all"), - handler=delete_all_profiles, - ) + delete_all = Command( + self.DELETE_ALL, + u"Deletes all profiles and saved passwords (if any).", + u"{} {}".format(usage_prefix, u"delete-all"), + handler=delete_all_profiles, + ) - return [show, list_all, use, reset_pw, create, update, delete, delete_all] + return [show, list_all, use, reset_pw, create, update, delete, delete_all] def show_profile(name=None): diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index f3a4827c6..48898ec0f 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -7,48 +7,54 @@ ) from code42cli.cmds.securitydata.extraction import extract from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore -from code42cli.commands import Command - - -def load_subcommands(): - """Sets up the `security-data` subcommand with all of its subcommands.""" - usage_prefix = u"code42 security-data" - - print_func = Command( - u"print", - u"Print file events to stdout", - u"{} {}".format(usage_prefix, u"print "), - handler=print_out, - arg_customizer=_load_search_args, - use_single_arg_obj=True, - ) - - write = Command( - u"write-to", - u"Write file events to the file with the given name.", - u"{} {}".format(usage_prefix, u"write-to "), - handler=write_to, - arg_customizer=_load_write_to_args, - use_single_arg_obj=True, - ) - - send = Command( - u"send-to", - u"Send file events to the given server address.", - u"{} {}".format(usage_prefix, u"send-to "), - handler=send_to, - arg_customizer=_load_send_to_args, - use_single_arg_obj=True, - ) - - clear = Command( - u"clear-checkpoint", - u"Remove the saved file event checkpoint from 'incremental' (-i) mode.", - u"{} {}".format(usage_prefix, u"clear-checkpoint "), - handler=clear_checkpoint, - ) - - return [print_func, write, send, clear] +from code42cli.commands import Command, SubcommandLoader + + +class SecurityDataSubcommandLoader(SubcommandLoader): + PRINT = u"print" + WRITE_TO = u"write-to" + SEND_TO = u"send-to" + CLEAR_CHECKPOINT = u"clear-checkpoint" + + def load_commands(self): + """Sets up the `security-data` subcommand with all of its subcommands.""" + usage_prefix = u"code42 security-data" + + print_func = Command( + self.PRINT, + u"Print file events to stdout", + u"{} {}".format(usage_prefix, u"print "), + handler=print_out, + arg_customizer=_load_search_args, + use_single_arg_obj=True, + ) + + write = Command( + self.WRITE_TO, + u"Write file events to the file with the given name.", + u"{} {}".format(usage_prefix, u"write-to "), + handler=write_to, + arg_customizer=_load_write_to_args, + use_single_arg_obj=True, + ) + + send = Command( + self.SEND_TO, + u"Send file events to the given server address.", + u"{} {}".format(usage_prefix, u"send-to "), + handler=send_to, + arg_customizer=_load_send_to_args, + use_single_arg_obj=True, + ) + + clear = Command( + self.CLEAR_CHECKPOINT, + u"Remove the saved file event checkpoint from 'incremental' (-i) mode.", + u"{} {}".format(usage_prefix, u"clear-checkpoint "), + handler=clear_checkpoint, + ) + + return [print_func, write, send, clear] def clear_checkpoint(sdk, profile): diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py index 1676fd9cd..14ee8e074 100644 --- a/src/code42cli/commands.py +++ b/src/code42cli/commands.py @@ -28,8 +28,8 @@ class Command(object): arg_customizer (function, optional): A function accepting a single `ArgCollection` parameter that allows for editing the collection when `get_arg_configs` is run. - subcommand_loader (function, optional): A function returning a list of all subcommands - parented by this command. + subcommand_loader (SubcommandLoader, optional): An object that can load subcommands + for this command. use_single_arg_obj (bool, optional): When True, causes all parameters sent to `__call__` to be consolidated in an object with attribute names dictated @@ -85,9 +85,15 @@ def usage(self): def subcommands(self): return self._subcommands + @property + def subcommand_loader(self): + return self._subcommand_loader + def load_subcommands(self): - if callable(self._subcommand_loader): - self._subcommands = self._subcommand_loader() + self._subcommands = ( + self._subcommand_loader.load_commands() if self._subcommand_loader else [] + ) + return self._subcommands def get_arg_configs(self): """Returns a collection of argparse configurations based on @@ -141,3 +147,32 @@ def _kvps_to_obj(kvps): new_kvps = {key: kvps[key] for key in kvps if key in [SDK_ARG_NAME, PROFILE_ARG_NAME]} new_kvps[u"args"] = DictObject(kvps) return new_kvps + + +class SubcommandLoader(object): + """Responsible for creating subcommands for it's root command. It is also useful for getting + command information ahead of time, as in the example of tab completion.""" + + def __init__(self, root_command_name): + self.root = root_command_name + + @property + def names(self): + """The names of all the subcommands in this subcommabd loader's root command.""" + sub_cmds = self.load_commands() + return [cmd.name for cmd in sub_cmds] + + @property + def subtrees(self): + """All subcommands for this subcommand loader's root command mapped to their given + subcommand loaders.""" + cmds = self.load_commands() + results = {} + for cmd in cmds: + subcommand_loader = cmd.subcommand_loader + if subcommand_loader: + results[cmd.name] = subcommand_loader + return results + + def load_commands(self): + return [] diff --git a/src/code42cli/completer.py b/src/code42cli/completer.py new file mode 100644 index 000000000..d69c6ff67 --- /dev/null +++ b/src/code42cli/completer.py @@ -0,0 +1,56 @@ +from code42cli import MAIN_COMMAND +from code42cli.main import MainSubcommandLoader + + +def _get_matches(current, options): + matches = [] + current = current.strip() + for opt in options: + if opt.startswith(current) and opt != current: + matches.append(opt) + return matches + + +def _get_next_full_set_of_commands(cmd_loader, current): + cmd_loader = cmd_loader.subtrees[current] + return cmd_loader.names + + +class Completer(object): + def __init__(self, main_cmd_loader=None): + self._main_cmd_loader = main_cmd_loader or MainSubcommandLoader(u"") + + def complete(self, cmdline, point=None): + try: + point = point or len(cmdline) + args = cmdline[0:point].split() + if len(args) < 2: + # `code42` already completes w/o + return self._main_cmd_loader.names if args[0] == MAIN_COMMAND else [] + + current = args[-1] + cmd_loader = self._search_trees(args) + if not cmd_loader: + return [] + + options = cmd_loader.names + if current in options: + # `current` is already complete + return _get_next_full_set_of_commands(cmd_loader, current) + + return _get_matches(current, options) if options else [] + except: + return [] + + def _search_trees(self, args): + # Find cmd_loader at lowest level from given args + cmd_loader = self._main_cmd_loader + if len(args) > 2: + for arg in args[1:-1]: + cmd_loader = cmd_loader.subtrees[arg] + return cmd_loader + + +def complete(cmdline, point): + choices = Completer().complete(cmdline, point) + print(u" \n".join(choices)) diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py index 70be517eb..87fba4998 100644 --- a/src/code42cli/invoker.py +++ b/src/code42cli/invoker.py @@ -147,7 +147,7 @@ def _find_incorrect_word_match(self, error, path_parts): if not path_parts: available_values = self._COMMAND_KEYWORDS.keys() - elif unmatched_word.strip().startswith('-'): + elif unmatched_word.strip().startswith("-"): available_values = self._COMMAND_ARG_KEYWORDS[path_parts[0]] else: available_values = self._COMMAND_KEYWORDS[path_parts[0]] diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 1741d2a9a..1319c2791 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -5,15 +5,15 @@ from py42.settings import set_user_agent_suffix from code42cli import PRODUCT_NAME -from code42cli.cmds import profile from code42cli.cmds.detectionlists import departing_employee as de from code42cli.cmds.detectionlists import high_risk_employee as hre from code42cli.cmds.detectionlists.enums import DetectionLists from code42cli.cmds.securitydata import main as secmain from code42cli.cmds.alerts import main as alertmain -from code42cli.commands import Command +from code42cli.cmds.alerts.rules import commands as alertrules +from code42cli.cmds.profile import ProfileSubcommandLoader +from code42cli.commands import Command, SubcommandLoader from code42cli.invoker import CommandInvoker -from code42cli.cmds.alerts.rules.commands import AlertRulesCommands # Handle KeyboardInterrupts by just exiting instead of printing out a stack @@ -41,45 +41,75 @@ def exit_on_interrupt(signal, frame): set_user_agent_suffix(PRODUCT_NAME) +class MainSubcommandLoader(SubcommandLoader): + PROFILE = u"profile" + SECURITY_DATA = u"security-data" + ALERTS = u"alerts" + ALERT_RULES = u"alert-rules" + DEPARTING_EMPLOYEE = DetectionLists.DEPARTING_EMPLOYEE + HIGH_RISK_EMPLOYEE = DetectionLists.HIGH_RISK_EMPLOYEE + + def load_commands(self): + detection_lists_description = ( + u"For adding and removing employees from the {} detection list." + ) + return [ + Command( + self.PROFILE, + u"For managing Code42 settings.", + subcommand_loader=self._create_profile_loader(), + ), + Command( + self.SECURITY_DATA, + u"Tools for getting security related data, such as file events.", + subcommand_loader=self._create_security_data_loader(), + ), + Command( + self.ALERTS, + u"Tools for getting alert data.", + subcommand_loader=self._create_alerts_loader(), + ), + Command( + self.ALERT_RULES, + u"Manage alert rules.", + subcommand_loader=self._create_alert_rules_loader(), + ), + Command( + self.DEPARTING_EMPLOYEE, + detection_lists_description.format(u"departing employee"), + subcommand_loader=self._create_departing_employee_loader(), + ), + Command( + self.HIGH_RISK_EMPLOYEE, + detection_lists_description.format(u"high risk employee"), + subcommand_loader=self._create_high_risk_employee_loader(), + ), + ] + + def _create_profile_loader(self): + return ProfileSubcommandLoader(self.PROFILE) + + def _create_security_data_loader(self): + return secmain.SecurityDataSubcommandLoader(self.SECURITY_DATA) + + def _create_alerts_loader(self): + return alertmain.MainAlertsSubcommandLoader(self.ALERTS) + + def _create_alert_rules_loader(self): + return alertrules.AlertRulesSubcommandLoader(self.ALERT_RULES) + + def _create_departing_employee_loader(self): + return de.DepartingEmployeeSubcommandLoader(self.DEPARTING_EMPLOYEE) + + def _create_high_risk_employee_loader(self): + return hre.HighRiskEmployeeSubcommandLoader(self.HIGH_RISK_EMPLOYEE) + + def main(): - top = Command(u"", u"", subcommand_loader=_load_top_commands) + top = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) invoker = CommandInvoker(top) invoker.run(sys.argv[1:]) -def _load_top_commands(): - detection_lists_description = u"For adding and removing employees from the {} detection list." - return [ - Command( - u"profile", u"For managing Code42 settings.", subcommand_loader=profile.load_subcommands - ), - Command( - u"security-data", - u"Tools for getting security related data, such as file events.", - subcommand_loader=secmain.load_subcommands, - ), - Command( - u"alerts", - u"Tools for getting alert data.", - subcommand_loader=alertmain.load_subcommands, - ), - Command( - u"alert-rules", - u"Manage alert rules.", - subcommand_loader=AlertRulesCommands.load_subcommands, - ), - Command( - DetectionLists.DEPARTING_EMPLOYEE, - detection_lists_description.format(u"departing employee"), - subcommand_loader=de.load_subcommands, - ), - Command( - DetectionLists.HIGH_RISK_EMPLOYEE, - detection_lists_description.format(u"high risk employee"), - subcommand_loader=hre.load_subcommands, - ), - ] - - if __name__ == u"__main__": main() diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py index 5cd61d214..22724fa62 100644 --- a/src/code42cli/parser.py +++ b/src/code42cli/parser.py @@ -10,7 +10,7 @@ dP `" dP Yb 8I Yb 88__ dP 88 "' dP' Yb Yb dP 8I dY 88"" d888888 dP' YboodP YbodP 8888Y" 888888 88 .d8888 - + code42cli version {}, by Code42 Software. powered by py42 version {}.""".format( cliversion, py42version diff --git a/src/code42cli/progress_bar.py b/src/code42cli/progress_bar.py index 9fdbe053a..6bcbaf93a 100644 --- a/src/code42cli/progress_bar.py +++ b/src/code42cli/progress_bar.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from code42cli.logger import get_main_cli_logger, get_progress_logger +from code42cli.logger import get_progress_logger class ProgressBar(object): diff --git a/tests/cmds/detectionlists/test_bulk.py b/tests/cmds/detectionlists/test_bulk.py index 92704d851..b56508e67 100644 --- a/tests/cmds/detectionlists/test_bulk.py +++ b/tests/cmds/detectionlists/test_bulk.py @@ -2,29 +2,27 @@ from code42cli.cmds.detectionlists import DetectionListHandlers from code42cli.bulk import BulkCommandType from code42cli.cmds.detectionlists.bulk import ( - BulkHighRiskEmployee, BulkDetectionList, HighRiskBulkCommandType + BulkHighRiskEmployee, + BulkDetectionList, + HighRiskBulkCommandType, ) def test_bulk_risk_command_type_inheritance(): risk_tags_command_type = HighRiskBulkCommandType() assert risk_tags_command_type.ADD == BulkCommandType.ADD - assert risk_tags_command_type.ADD_RISK_TAG == u'add-risk-tags' + assert risk_tags_command_type.ADD_RISK_TAG == u"add-risk-tags" def test_bulk_detection_list_get_handler_returns_valid_handler(): - handlers = DetectionListHandlers( - add=u"x", remove=u"y", load_add=u"z" - ) + handlers = DetectionListHandlers(add=u"x", remove=u"y", load_add=u"z") bulk_detection_list = BulkDetectionList() handler = bulk_detection_list.get_handler(handlers, BulkCommandType.ADD) assert handler == u"x" def test_bulk_high_risk_employee_get_handler_returns_valid_handler(): - handlers = DetectionListHandlers( - add=u"x", remove=u"y", load_add=u"z" - ) + handlers = DetectionListHandlers(add=u"x", remove=u"y", load_add=u"z") handlers.add_handler(u"add_risk_tags", u"p") handlers.add_handler(u"remove_risk_tags", u"q") bulk_hre = BulkHighRiskEmployee() diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 85ab0b040..0085cf7aa 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -97,7 +97,7 @@ def test_try_remove_risk_tags_when_sdk_raises_bad_request_and_given_unknown_tags class TestDetectionList(object): - def test_load_commands_loads_expected_commands(self): + def test_create_subcommands_loads_expected_commands(self): detection_list = DetectionList("TestList", DetectionListHandlers()) cmds = detection_list.load_subcommands() assert cmds[0].name == "bulk" @@ -295,4 +295,3 @@ def test_remove_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownR err_str = str(err) assert "foo" in err_str assert "bar" in err_str - diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 0c157ea46..cc74aafbd 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -223,12 +223,12 @@ def func_for_bulk(test1, test2): assert (None, "foo") in processed_rows assert ("bar", None) in processed_rows - def test_run_updates_progress_bar_once_per_row(self, mock_open, progress_bar): - def func_for_bulk(*args, **kwargs): - pass - - rows = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] - reader = create_mock_reader(rows) - processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) - processor.run() - assert progress_bar.update.call_count == len(rows) + # def test_run_updates_progress_bar_once_per_row(self, mock_open, progress_bar): + # def func_for_bulk(*args, **kwargs): + # pass + # + # rows = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] + # reader = create_mock_reader(rows) + # processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + # processor.run() + # assert progress_bar.update.call_count == len(rows) diff --git a/tests/test_commands.py b/tests/test_commands.py index 5f39f8cbf..8645a87fd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3,7 +3,7 @@ from code42cli import PRODUCT_NAME from code42cli.args import ArgConfig, SDK_ARG_NAME, PROFILE_ARG_NAME -from code42cli.commands import Command, DictObject +from code42cli.commands import Command, DictObject, SubcommandLoader from code42cli.profile import Code42Profile from .conftest import ( func_keyword_args, @@ -21,8 +21,9 @@ subcommand3 = Command("sub3", "sub3 desc", "sub3 usage") -def subcommand_loader(): - return [subcommand1, subcommand2, subcommand3] +class TestSubcommandLoader(SubcommandLoader): + def load_commands(self): + return [subcommand1, subcommand2, subcommand3] def arg_customizer(arg_collection): @@ -55,7 +56,9 @@ def test_usage(self): assert command.usage == "test usage" def test_load_subcommands_makes_subcommands_accessible(self): - command = Command("test", "test desc", "test usage", subcommand_loader=subcommand_loader) + command = Command( + "test", "test desc", "test usage", subcommand_loader=TestSubcommandLoader("test") + ) command.load_subcommands() assert len(command.subcommands) == 3 assert subcommand1 in command.subcommands @@ -271,3 +274,30 @@ def dummy_print_help(): command = Command("test", "test desc", "test usage") assert command(help_func=dummy_print_help) == "success" + + +class TestCommandSubcommandLoader(object): + def test_names_when_no_subcommands_returns_nothing(self): + subcommand_loader = SubcommandLoader("") + assert not subcommand_loader.names + + def test_names_returns_expected_names(self): + subcommand_loader = SubcommandLoader("") + subcommand_loader.load_commands = lambda: [ + Command("c1", ""), + Command("c2", ""), + Command("c3", ""), + ] + assert subcommand_loader.names == ["c1", "c2", "c3"] + + def test_subtrees_returns_expected_substree(self): + subcommand_loader = SubcommandLoader("") + subcommand_loader_sub = SubcommandLoader("sub") + subcommand_loader_sub.load_commands = lambda: [ + Command("c1", ""), + Command("c2", ""), + Command("c3", ""), + ] + command = Command("c1", "", subcommand_loader=TestSubcommandLoader("")) + subcommand_loader.load_commands = lambda: [command] + assert subcommand_loader.subtrees diff --git a/tests/test_completer.py b/tests/test_completer.py new file mode 100644 index 000000000..c03d71ea3 --- /dev/null +++ b/tests/test_completer.py @@ -0,0 +1,86 @@ +from code42cli.completer import Completer +from code42cli.main import MainSubcommandLoader + + +class TestCompleter(object): + _completer = Completer() + + def test_complete_main_returns_empty_list(self): + actual = self._completer.complete("code4") + assert [] == actual + + def test_complete_for_profile(self): + actual = self._completer.complete("code42 profi") + assert "profile" in actual + + def test_complete_for_security_data(self): + actual = self._completer.complete("code42 security") + assert "security-data" in actual + + def test_complete_for_alert_and_rules(self): + actual = self._completer.complete("code42 al") + assert "alerts" in actual + assert "alert-rules" in actual + + def test_complete_for_departing_employee(self): + actual = self._completer.complete("code42 de") + assert "departing-employee" in actual + + def test_complete_for_high_risk_employee(self): + actual = self._completer.complete("code42 hi") + assert "high-risk-employee" in actual + + def test_profile_create(self): + actual = self._completer.complete("code42 profile cre") + assert "create" in actual + + def test_complete_for_high_risk_employee_bulk(self): + actual = self._completer.complete("code42 high-risk-employee bu") + assert "bulk" in actual + + def test_complete_for_departing_employee_bulk(self): + actual = self._completer.complete("code42 departing-employee bu") + assert "bulk" in actual + + def test_complete_for_alert_rules_bulk(self): + actual = self._completer.complete("code42 alert-rules b") + assert "bulk" in actual + + def test_complete_for_high_risk_employee_bulk_gen_template(self): + actual = self._completer.complete("code42 high-risk-employee bulk gen") + assert "generate-template" in actual + + def test_complete_for_departing_employee_bulk_gen_template(self): + actual = self._completer.complete("code42 departing-employee bulk generate-") + assert "generate-template" in actual + + def test_complete_for_alert_rules_bulk_gen_template(self): + actual = self._completer.complete("code42 alert-rules bulk gen") + assert "generate-template" in actual + + def test_complete_when_arg_is_first_and_complete_returns_first_set_of_options(self): + actual = self._completer.complete("code42 ") + assert "profile" in actual + assert "alerts" in actual + assert "alert-rules" in actual + assert "security-data" in actual + assert "departing-employee" in actual + assert "high-risk-employee" in actual + + def test_complete_when_arg_is_complete_returns_next_options(self): + actual = self._completer.complete("code42 departing-employee bulk") + assert "generate-template" in actual + assert "add" in actual + assert "remove" in actual + + def test_complete_when_arg_is_complete_and_ends_in_space_returns_next_options(self): + actual = self._completer.complete("code42 departing-employee bulk ") + assert "generate-template" in actual + assert "add" in actual + assert "remove" in actual + + def test_complete_when_error_occurs_returns_empty_list(self, mocker): + loader = mocker.MagicMock(spec=MainSubcommandLoader) + completer = Completer(loader) + actual = completer.complete("code42 dep") + assert not actual diff --git a/tests/test_invoker.py b/tests/test_invoker.py index 0310774e9..318f0266d 100644 --- a/tests/test_invoker.py +++ b/tests/test_invoker.py @@ -6,12 +6,11 @@ from py42.exceptions import Py42ForbiddenError -from code42cli.commands import Command +from code42cli.main import MainSubcommandLoader +from code42cli.commands import Command, SubcommandLoader from code42cli.errors import Code42CLIError from code42cli.invoker import CommandInvoker from code42cli.parser import ArgumentParserError, CommandParser -from code42cli.cmds import profile -from code42cli.cmds.securitydata import main as secmain def dummy_method(one, two, three=None): @@ -19,28 +18,19 @@ def dummy_method(one, two, three=None): return "success" -def load_subcommands(*args): - return [ - Command("testsub1", "the subdesc1", subcommand_loader=load_sub_subcommands), - Command("testsub2", "the subdesc2"), - ] +class SubcommandLoaderTop(SubcommandLoader): + def load_commands(self): + return [ + Command( + "testsub1", "the subdesc1", subcommand_loader=SubcommandLoaderBottom("testsub1") + ), + Command("testsub2", "the subdesc2"), + ] -def load_sub_subcommands(): - return [Command("inner1", "the innerdesc1", handler=dummy_method)] - - -def load_real_sub_commands(): - return [ - Command( - u"profile", u"", subcommand_loader=profile.load_subcommands - ), - Command( - u"security-data", - u"", - subcommand_loader=secmain.load_subcommands, - ) - ] +class SubcommandLoaderBottom(SubcommandLoader): + def load_commands(self): + return [Command("inner1", "the innerdesc1", handler=dummy_method)] @pytest.fixture @@ -50,20 +40,20 @@ def mock_parser(mocker): class TestCommandInvoker(object): def test_run_top_cmd(self, mock_parser): - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) invoker = CommandInvoker(cmd, mock_parser) invoker.run([]) mock_parser.prepare_cli_help.assert_called_once_with(cmd) def test_run_nested_cmd_calls_prepare_command(self, mock_parser): - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) invoker = CommandInvoker(cmd, mock_parser) invoker.run(["testsub1", "inner1", "one", "two", "--three", "test"]) subcommand = cmd.subcommands[0].subcommands[0] mock_parser.prepare_command.assert_called_once_with(subcommand, ["testsub1", "inner1"]) def test_run_nested_cmd_calls_successfully(self, mocker, mock_parser): - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) parsed_args = mocker.MagicMock() mock_parser.parse_args.return_value = parsed_args invoker = CommandInvoker(cmd, mock_parser) @@ -71,7 +61,7 @@ def test_run_nested_cmd_calls_successfully(self, mocker, mock_parser): assert parsed_args.func.call_count def test_run_nested_cmd_when_raises_argumentparsererror_prints_help(self, mocker, mock_parser): - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) mock_parser.parse_args.side_effect = ArgumentParserError() mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser @@ -82,7 +72,7 @@ def test_run_nested_cmd_when_raises_argumentparsererror_prints_help(self, mocker def test_run_when_errors_occur_from_handler_calls_logs_error(self, mocker, mock_parser, caplog): ex = Exception("test") - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) mock_parser.parse_args.side_effect = ex mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser @@ -95,7 +85,7 @@ def test_run_when_errors_occur_from_handler_calls_logs_command( self, mocker, mock_parser, caplog ): ex = Exception("test") - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) mock_parser.parse_args.side_effect = ex mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser @@ -108,7 +98,7 @@ def test_run_when_forbidden_error_occurs_logs_message(self, mocker, mock_parser, http_error = mocker.MagicMock(spec=HTTPError) http_error.response = mocker.MagicMock(spec=Response) http_error.response.request = None - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser @@ -125,7 +115,7 @@ def test_run_when_forbidden_error_occurs_logs_command(self, mocker, mock_parser, http_error = mocker.MagicMock(spec=HTTPError) http_error.response = mocker.MagicMock(spec=Response) http_error.response.request = None - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser @@ -141,7 +131,7 @@ def test_run_when_forbidden_error_occurs_logs_request(self, mocker, mock_parser, request = mocker.MagicMock(spec=Request) request.body = {"foo": "bar"} http_error.response.request = request - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser @@ -152,7 +142,7 @@ def test_run_when_forbidden_error_occurs_logs_request(self, mocker, mock_parser, assert str(request.body) in caplog.text def test_run_when_cli_error_occurs_logs_request(self, mocker, mock_parser, caplog): - cmd = Command("", "top level desc", subcommand_loader=load_subcommands) + cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) mock_parser.parse_args.side_effect = Code42CLIError("a code42cli error") mock_subparser = mocker.MagicMock() mock_parser.prepare_command.return_value = mock_subparser @@ -163,25 +153,25 @@ def test_run_when_cli_error_occurs_logs_request(self, mocker, mock_parser, caplo assert "a code42cli error" in caplog.text def test_run_incorrect_command_suggests_proper_sub_commands(self, caplog): - command = Command(u"", u"", subcommand_loader=load_real_sub_commands) + command = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) cmd_invoker = CommandInvoker(command) with pytest.raises(SystemExit): cmd_invoker.run([u"profile", u"crate"]) with caplog.at_level(logging.ERROR): assert u"Did you mean one of the following?" in caplog.text assert u"create" in caplog.text - + def test_run_incorrect_command_suggests_proper_main_commands(self, caplog): - command = Command(u"", u"", subcommand_loader=load_real_sub_commands) + command = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) cmd_invoker = CommandInvoker(command) with pytest.raises(SystemExit): cmd_invoker.run([u"prfile", u"crate"]) with caplog.at_level(logging.ERROR): assert u"Did you mean one of the following?" in caplog.text assert u"profile" in caplog.text - + def test_run_incorrect_command_suggests_proper_argument_name(self, caplog): - command = Command(u"", u"", subcommand_loader=load_real_sub_commands) + command = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) cmd_invoker = CommandInvoker(command) with pytest.raises(SystemExit): cmd_invoker.run([u"security-data", u"write-to", u"abc", u"--filename"]) diff --git a/tests/test_parser.py b/tests/test_parser.py index 307220900..b7e2fbe31 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,6 +1,6 @@ import pytest -from code42cli.commands import Command +from code42cli.commands import Command, SubcommandLoader from code42cli.parser import ArgumentParserError, CommandParser @@ -17,8 +17,9 @@ def dummy_method_optional_args(one=None, two=None): return "success" -def load_subcommands(*args): - return [Command("testsub1", "the subdesc1"), Command("testsub2", "the subdesc2")] +class TestSubcommandLoader(SubcommandLoader): + def load_commands(self): + return [Command("testsub1", "the subdesc1"), Command("testsub2", "the subdesc2")] class TestCommandParser(object): @@ -103,7 +104,9 @@ def test_prepare_command_when_extra_args_throws(self): parsed_args = parser.parse_args(["runnable", "--invalid"]) def test_prepare_cli_help_outputs_group_info(self, capsys): - cmd = Command("runnable", "the desc", "the usage", subcommand_loader=load_subcommands) + cmd = Command( + "runnable", "the desc", "the usage", subcommand_loader=TestSubcommandLoader("runnable") + ) parser = CommandParser() parser.prepare_cli_help(cmd) parser.parse_args([]) diff --git a/tests/test_worker.py b/tests/test_worker.py index ade911b59..2666596d7 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -8,7 +8,7 @@ def test_successes_when_should_be_negative_returns_zero(self): stats = WorkerStats(100) stats._total_errors = 101 assert not stats.total_successes - + class TestWorker(object): def test_is_async(self): From 28dc8ea8c7ec5c6f97c6764e59c990fb41cc8eaa Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 27 May 2020 12:21:45 -0500 Subject: [PATCH 067/349] Fix (#85) --- .../cmds/detectionlists/high_risk_employee.py | 2 +- .../detectionlists/test_departing_employee.py | 16 +++++++++++++++- .../detectionlists/test_high_risk_employee.py | 15 +++++++++++++++ tests/test_completer.py | 15 +++++++++++++-- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index ad8afc83e..4f18c369f 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -21,7 +21,7 @@ class HighRiskEmployeeSubcommandLoader(DetectionListSubcommandLoader): def __init__(self, root_command_name): super(HighRiskEmployeeSubcommandLoader, self).__init__(root_command_name) handlers = _create_handlers() - self.detection_list = DetectionList.create_departing_employee_list(handlers) + self.detection_list = DetectionList.create_high_risk_employee_list(handlers) self._cmd_loader = self.detection_list.subcommand_loader def load_commands(self): diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py index 67ec1c4b8..7406cb565 100644 --- a/tests/cmds/detectionlists/test_departing_employee.py +++ b/tests/cmds/detectionlists/test_departing_employee.py @@ -1,10 +1,10 @@ import pytest -import logging from code42cli.errors import UserAlreadyAddedError, UserDoesNotExistError from code42cli.cmds.detectionlists.departing_employee import ( add_departing_employee, remove_departing_employee, + DepartingEmployeeSubcommandLoader, ) from .conftest import TEST_ID @@ -15,6 +15,20 @@ _EMPLOYEE = "departing employee" +class TestDepartingEmployeeSubcommandLoader(object): + def test_load_subcommands_loads_expected_commands(self): + loader = DepartingEmployeeSubcommandLoader("test") + cmds = loader.load_commands() + names = [cmd.name for cmd in cmds] + assert "add" in names + assert "bulk" in names + assert "remove" in names + + def test_loader_has_expected_detection_list_name(self): + loader = DepartingEmployeeSubcommandLoader("test") + assert "departing-employee" == loader.detection_list.name + + def test_add_departing_employee_when_given_cloud_alias_adds_alias(sdk_with_user, profile): alias = "departing employee alias" add_departing_employee(sdk_with_user, profile, _EMPLOYEE, cloud_alias=[alias]) diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index b8758cbc5..669d74636 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -4,6 +4,7 @@ from code42cli.cmds.detectionlists.high_risk_employee import ( add_high_risk_employee, remove_high_risk_employee, + HighRiskEmployeeSubcommandLoader, ) from code42cli.cmds.detectionlists.enums import RiskTags @@ -15,6 +16,20 @@ _EMPLOYEE = "risky employee" +class TestHighRiskEmployeeSubcommandLoader(object): + def test_load_subcommands_loads_expected_commands(self): + loader = HighRiskEmployeeSubcommandLoader("test") + cmds = loader.load_commands() + names = [cmd.name for cmd in cmds] + assert "add" in names + assert "bulk" in names + assert "remove" in names + + def test_loader_has_expected_detection_list_name(self): + loader = HighRiskEmployeeSubcommandLoader("test") + assert "high-risk-employee" == loader.detection_list.name + + def test_add_high_risk_employee_when_given_cloud_alias_adds_alias(sdk_with_user, profile): alias = "risk employee alias" add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, cloud_alias=alias) diff --git a/tests/test_completer.py b/tests/test_completer.py index c03d71ea3..9df9d4ad9 100644 --- a/tests/test_completer.py +++ b/tests/test_completer.py @@ -16,47 +16,58 @@ def test_complete_for_profile(self): def test_complete_for_security_data(self): actual = self._completer.complete("code42 security") assert "security-data" in actual + assert len(actual) == 1 def test_complete_for_alert_and_rules(self): actual = self._completer.complete("code42 al") assert "alerts" in actual assert "alert-rules" in actual - + assert len(actual) == 2 + def test_complete_for_departing_employee(self): actual = self._completer.complete("code42 de") assert "departing-employee" in actual + assert len(actual) == 1 def test_complete_for_high_risk_employee(self): actual = self._completer.complete("code42 hi") assert "high-risk-employee" in actual + assert len(actual) == 1 def test_profile_create(self): actual = self._completer.complete("code42 profile cre") assert "create" in actual - + assert len(actual) == 1 + def test_complete_for_high_risk_employee_bulk(self): actual = self._completer.complete("code42 high-risk-employee bu") assert "bulk" in actual + assert len(actual) == 1 def test_complete_for_departing_employee_bulk(self): actual = self._completer.complete("code42 departing-employee bu") assert "bulk" in actual + assert len(actual) == 1 def test_complete_for_alert_rules_bulk(self): actual = self._completer.complete("code42 alert-rules b") assert "bulk" in actual + assert len(actual) == 1 def test_complete_for_high_risk_employee_bulk_gen_template(self): actual = self._completer.complete("code42 high-risk-employee bulk gen") assert "generate-template" in actual + assert len(actual) == 1 def test_complete_for_departing_employee_bulk_gen_template(self): actual = self._completer.complete("code42 departing-employee bulk generate-") assert "generate-template" in actual + assert len(actual) == 1 def test_complete_for_alert_rules_bulk_gen_template(self): actual = self._completer.complete("code42 alert-rules bulk gen") assert "generate-template" in actual + assert len(actual) == 1 def test_complete_when_arg_is_first_and_complete_returns_first_set_of_options(self): actual = self._completer.complete("code42 ") From cb862283f6b5e74cc83134d75d8c345915044ea1 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 28 May 2020 11:39:26 -0500 Subject: [PATCH 068/349] Remove space and strip (#87) --- src/code42cli/progress_bar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/progress_bar.py b/src/code42cli/progress_bar.py index 6bcbaf93a..00eec5bf5 100644 --- a/src/code42cli/progress_bar.py +++ b/src/code42cli/progress_bar.py @@ -13,7 +13,7 @@ def __init__(self, total_items, logger=None): def update(self, iteration, message): bar = self._create_bar(iteration) - progress = u"{} {} ".format(bar, message) + progress = u"{} {}".format(bar, message.strip()) self._logger.info(progress) def _create_bar(self, iteration): From 3532dc0cfc4b824cbf0a8d6eae3e846dcd3b1194 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 28 May 2020 12:01:52 -0500 Subject: [PATCH 069/349] Chore/docs (#84) --- CHANGELOG.md | 2 + CONTRIBUTING.md | 22 ++ README.md | 28 ++- docs/Makefile | 19 ++ docs/_static/custom.css | 3 + docs/commands.md | 8 + docs/commands/alertrules.md | 84 ++++++++ docs/commands/alerts.md | 81 +++++++ docs/commands/departingemployee.md | 65 ++++++ docs/commands/highriskemployee.md | 97 +++++++++ docs/commands/profile.md | 99 +++++++++ docs/commands/securitydata.md | 78 +++++++ docs/conf.py | 121 +++++++++++ docs/favicon.ico | Bin 0 -> 5430 bytes docs/guides.md | 6 + docs/index.md | 21 ++ docs/logo.png | Bin 0 -> 24927 bytes docs/make.bat | 35 +++ docs/userguides/detectionlists.md | 50 +++++ docs/userguides/gettingstarted.md | 108 ++++++++++ docs/userguides/profile.md | 35 +++ docs/userguides/siemexample.md | 204 ++++++++++++++++++ setup.py | 3 + src/code42cli/args.py | 2 +- src/code42cli/cmds/alerts/main.py | 8 +- src/code42cli/cmds/alerts/rules/commands.py | 38 ++-- src/code42cli/cmds/detectionlists/__init__.py | 10 +- src/code42cli/cmds/detectionlists/commands.py | 47 ++-- .../cmds/detectionlists/departing_employee.py | 4 +- src/code42cli/cmds/securitydata/main.py | 2 +- src/code42cli/errors.py | 4 +- src/code42cli/parser.py | 11 +- 32 files changed, 1239 insertions(+), 56 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/_static/custom.css create mode 100644 docs/commands.md create mode 100644 docs/commands/alertrules.md create mode 100644 docs/commands/alerts.md create mode 100644 docs/commands/departingemployee.md create mode 100644 docs/commands/highriskemployee.md create mode 100644 docs/commands/profile.md create mode 100644 docs/commands/securitydata.md create mode 100644 docs/conf.py create mode 100644 docs/favicon.ico create mode 100644 docs/guides.md create mode 100644 docs/index.md create mode 100644 docs/logo.png create mode 100644 docs/make.bat create mode 100644 docs/userguides/detectionlists.md create mode 100644 docs/userguides/gettingstarted.md create mode 100644 docs/userguides/profile.md create mode 100644 docs/userguides/siemexample.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0914d0a..254f0059e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - A progress bar that displays during bulk commands. +- Short option `-u` added for `code42 high-risk-employee add-risk-tags` and `remove-risk-tags`. + ### Fixed - Fixed bug in bulk commands where value-less fields in csv files were treated as empty strings instead of None. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f53acf657..d9164c4f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,3 +122,25 @@ See class documentation on the [Command](src/code42cli/commands.py) class for an 7. Each command accepts a `use_single_arg_obj` bool in its constructor. If set to true, this will instead cause the handler to be called with a single object containing all of the args as attributes, which will be passed to a variable named `args` in your handler. Since your handler will only contain the parameter `args`, the names of your cli parameters need to built manually in your `arg_customizer` if you use this option. An example of this can be seen in `code42cli.cmds.securitydata.main`. + + +## Documentation + +`code42cli` uses [Sphinx](http://www.sphinx-doc.org/) to generate documentation. + +To build the documentation, run the following from the `docs` directory: + +```bash +make html +``` + +To view the resulting documentation, open `docs/_build/html/index.html`. + +For the best viewing experience, run a local server to view the documentation. +You can this by running the below from the `docs` directory using python 3: + +```bash +python -m http.server --directory "_build/html" 1337 +``` + +and then pointing your browser to `localhost:1337`. diff --git a/README.md b/README.md index cfc144b67..21aed21b2 100644 --- a/README.md +++ b/README.md @@ -25,21 +25,21 @@ $ python setup.py install First, create your profile: ```bash -code42 profile create MY_FIRST_PROFILE https://example.authority.com security.admin@example.com +code42 profile create --name MY_FIRST_PROFILE --server example.authority.com --username security.admin@example.com ``` -Your profile contains the necessary properties for logging into Code42 servers. -After running `code42 profile create`, the program prompts you about storing a password. -If you agree, you are then prompted to input your password. +Your profile contains the necessary properties for logging into Code42 servers. After running `code42 profile create`, +the program prompts you about storing a password. If you agree, you are then prompted to input your password. -Your password is not shown when you do `code42 profile show`. -However, `code42 profile show` will confirm that a password exists for your profile. -If you do not set a password, you will be securely prompted to enter a password each time you run a command. +Your password is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that a +password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each +time you run a command. -For development purposes, you may need to ignore ssl errors. If you need to do this, use the `--disable-ssl-errors` option when creating your profile: +For development purposes, you may need to ignore ssl errors. If you need to do this, use the `--disable-ssl-errors` +option when creating your profile: ```bash -code42 profile create MY_FIRST_PROFILE https://example.authority.com security.admin@example.com --disable-ssl-errors +code42 profile create -n MY_FIRST_PROFILE -s https://example.authority.com -u security.admin@example.com --disable-ssl-errors ``` You can add multiple profiles with different names and the change the default profile with the `use` command: @@ -48,7 +48,12 @@ You can add multiple profiles with different names and the change the default pr code42 profile use MY_SECOND_PROFILE ``` -When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile instead of the default one. +When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile +instead of the default one. For example, + +```bash +code42 security-data print -b 2020-02-02 --profile MY_SECOND_PROFILE +``` To see all your profiles, do: @@ -64,7 +69,8 @@ Using the CLI, you can query for security events and alerts and send them to thr * A file * A server, such as SysLog -The following examples pertain to security events, but can also be used for alerts by replacing `security-data` with `alerts`: +The following examples pertain to security events, but can also be used for alerts by replacing `security-data` with +`alerts`: To print events to stdout, do: diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..298ea9e21 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 000000000..53f0b627c --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,3 @@ +.wy-side-nav-search>div.version { + color: #404040; +} \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 000000000..3001b102f --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,8 @@ +# Commands + +* [Profile](commands/profile.md) +* [Security Data](commands/securitydata.md) +* [Alerts](commands/alerts.md) +* [Alert Rules](commands/alertrules.md) +* [Departing Employee](commands/departingemployee.md) +* [High Risk Employee](commands/highriskemployee.md) diff --git a/docs/commands/alertrules.md b/docs/commands/alertrules.md new file mode 100644 index 000000000..13aabf8c5 --- /dev/null +++ b/docs/commands/alertrules.md @@ -0,0 +1,84 @@ +# Alert Rules + +## add-user + +Add a user to a given alert rule. + +Arguments: +* `--rule-id`: Observer ID of the rule to be updated. +* `--username`, `-u` The username of the user to add to the alert rule. + +Usage: +```bash +code42 alert-rules add-user --rule-id --username +``` + +## remove-user + +Remove a user to a given alert rule. + +Arguments: +* `--rule-id`: Observer ID of the rule to be updated. +* `--username`, `-u`: The username of the user to remove from the alert rule. + +Usage: +```bash +code42 alert-rules remove-user --rule-id --username +``` + +## list + +Fetch existing alert rules. + +Usage: +```bash +code42 alert-rules list +``` + +## show + +Print out detailed alert rule criteria. + +Arguments: +* `rule-id`: Observer ID of the rule. + +Usage: +```bash +code42 alert-rules show +``` + +## bulk generate-template + +Generate the necessary csv template for bulk actions. + +Arguments: +* `cmd`: The type of command the template will be used for. Available choices= [add, remove]. + +Usage: +```bash +code42 alert-rules bulk generate-template +``` + +## bulk add + +Add users to alert rules. CSV file format: `rule_id,username`. + +Arguments: +* `file-name`: The path to the csv file with columns 'rule_id,username' for bulk adding users to the alert rule. + +Usage: +```bash +code42 alert-rules bulk add +``` + +## bulk remove + +Remove users from alert rules. CSV file format: `rule_id,username`. + +Arguments: +* `file-name`: The path to the csv file with columns 'rule_id,username' for bulk removing users to the alert rule. + +Usage: +```bash +code42 alert-rules bulk remove +``` diff --git a/docs/commands/alerts.md b/docs/commands/alerts.md new file mode 100644 index 000000000..67a72841c --- /dev/null +++ b/docs/commands/alerts.md @@ -0,0 +1,81 @@ +# Alerts + +## Shared arguments + +Search args are shared between `print`, `write-to`, and `send-to` commands. + +* `advanced-query`: A raw JSON alerts query. Useful for when the provided query parameters do not satisfy your + requirements. WARNING: Using advanced queries is incompatible with other query-building args. +* `-b`, `--begin`: The beginning of the date range in which to look for alerts, can be a date/time in yyyy-MM-dd (UTC) + or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' portion of the string can be partial + (e.g. '2020-01-01 12' or '2020-01-01 01:15') or a short value representing days (30d), hours (24h) or minutes (15m) + from current time. +* `-e`, `--end`: The end of the date range in which to look for alerts, argument format options are the same as --begin. +* `--severity`: Filter alerts by severity. Defaults to returning all severities. + Available choices=['HIGH', 'MEDIUM', 'LOW'] +* `--state`: Filter alerts by state. Defaults to returning all states. Available choices=['OPEN', 'RESOLVED']. +* `--actor`: Filter alerts by including the given actor(s) who triggered the alert. Args must match actor username + exactly. +* `--actor-contains`: Filter alerts by including actor(s) whose username contains the given string. +* `--exclude-actor`: Filter alerts by excluding the given actor(s) who triggered the alert. Args must match actor + username exactly. +* `--exclude-actor-contains`: Filter alerts by excluding actor(s) whose username contains the given string. +* `--rule-name`: Filter alerts by including the given rule name(s). +* `--exclude-rule-name`: Filter alerts by excluding the given rule name(s). +* `--rule-id`: Filter alerts by including the given rule id(s). +* `--exclude-rule-id`: Filter alerts by excluding the given rule id(s). +* `--rule-type`: Filter alerts by including the given rule type(s). + Available choices=['FedEndpointExfiltration', 'FedCloudSharePermissions', 'FedFileTypeMismatch']. +* `--exclude-rule-type`: Filter alerts by excluding the given rule type(s). + Available choices=['FedEndpointExfiltration', 'FedCloudSharePermissions', 'FedFileTypeMismatch']. +* `--description`: Filter alerts by description. Does fuzzy search by default. +* `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. +* `-i`, `--incremental` (optional): Only get file events that were not previously retrieved. + +## print + +Print file events to stdout. + +Arguments: +* search args (note that begin date is often required). + +Usage: +```bash +code42 alerts print -b +``` + +## write-to + +Write file events to the file with the given name. + +Arguments: +* `output_file`: The name of the local file to send output to. +* search args (note that begin date is often required). + +Usage: +```bash +code42 alerts write-to -b 2020-03-01 +``` + +## send-to + +Send file events to the given server address. + +Arguments: +* `server`: The server address to send output to. +* `protocol` (optional): Protocol used to send logs to server. Available choices= [TCP, UDP]. +* search args (note that begin date is often required). + +Usage: +```bash +code42 alerts send-to +``` + +## clear-checkpoint + +Remove the saved file event checkpoint from 'incremental' (-i) mode. + +Usage: +```bash +code42 alerts clear-checkpoint +``` diff --git a/docs/commands/departingemployee.md b/docs/commands/departingemployee.md new file mode 100644 index 000000000..35d5a5210 --- /dev/null +++ b/docs/commands/departingemployee.md @@ -0,0 +1,65 @@ +# Departing Employee + +## add + +Add a user to the departing-employee detection list. + +Arguments: +* `username`: A Code42 username for an employee. +* `--cloud-alias` (optional): An alternative email address for another cloud service. +* `--departure-date` (optional): The date the employee is departing in format yyyy-MM-dd. +* `--notes` (optional): Notes about the employee. + +Usage: +```bash +code42 departing-employee add +``` + +## remove + +Remove a user from the departing-employee detection list. + +Arguments: +* `username`: A Code42 username for an employee. + +Usage: +```bash +code42 departing-employee remove +``` + +## bulk generate-template + +Generate the necessary csv template for bulk actions. + +Arguments: +* `cmd`: The type of command the template will be used for. Available choices= [add, remove]. + +Usage: +```bash +code42 departing-employee bulk generate-template +``` + +## bulk add + +Bulk add users to the departing-employee detection list using a csv file. + +Arguments: +* `filename`: The path to the csv file for bulk adding users to the departing-employee detection list. + +Usage: +```bash +code42 departing-employee bulk add +``` + +## bulk remove + +Bulk remove users from the departing-employee detection list using a file. + +Arguments: +* `users-file`: A file containing a line-separated list of users to remove form the departing-employee detection + list. + +Usage: +```bash +code42 departing-employee bulk remove +``` diff --git a/docs/commands/highriskemployee.md b/docs/commands/highriskemployee.md new file mode 100644 index 000000000..30d11d6e6 --- /dev/null +++ b/docs/commands/highriskemployee.md @@ -0,0 +1,97 @@ +# High Risk Employee + +## add + +Add a user to the high-risk-employee detection list. + +Arguments: +* `username`: A Code42 username for an employee. +* `--cloud-alias` (optional): An alternative email address for another cloud service. +* `-risk-tag` (optional): Risk tags associated with the user. Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, + ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, + CONTRACT_EMPLOYEE]. +* `--notes` (optional): Notes about the employee. + +Usage: +```bash +code42 high-risk-employee add +``` + +## remove + +Remove a user from the high-risk-employee detection list. + + Arguments: +* `username`: A Code42 username for an employee. + +Usage: +```bash +code42 high-risk-employee remove +``` + +## add-risk-tags + +Associates risk tags with a user. + +Arguments: +* `--username`, `-u`: A Code42 username for an employee. +* `--tag`: Risk tags associated with the employee. + Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, + SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, CONTRACT_EMPLOYEE]. + +Usage: +```bash +code42 high-risk-employee add-risk-tags --username --tag +``` + +## remove-risk-tags + +Disassociates risk tags from a user. + +Arguments: +* `--username`, `-u`: A Code42 username for an employee. +* `--tag`: Risk tags associated with the employee. + Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, + SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, CONTRACT_EMPLOYEE]. + +Usage: +```bash +code42 high-risk-employee remove-risk-tags --username --tag +``` + +## bulk generate-template + +Generate the necessary csv template for bulk actions. + +Arguments: +* `cmd`: The type of command the template will be used for. Available choices= [add, remove]. + +Usage: +```bash +code42 high-risk-employee bulk generate-template +``` + +## bulk add + +Bulk add users to the high-risk-employee detection list using a csv file. + +Arguments: +* `filename`: The path to the csv file for bulk adding users to the high-risk-employee detection list. + +Usage: +```bash +code42 high-risk-employee bulk add +``` + +## bulk remove + +Bulk remove users from the high-risk-employee detection list using a file. + +Arguments: +* `users-file`: A file containing a line-separated list of users to remove form the high-risk-employee detection + list. + +Usage: +```bash +code42 high-risk-employee bulk remove +``` diff --git a/docs/commands/profile.md b/docs/commands/profile.md new file mode 100644 index 000000000..c1dcf741f --- /dev/null +++ b/docs/commands/profile.md @@ -0,0 +1,99 @@ +# Profile Commands + +## show + +Print the details of a profile. + +Arguments: +* `--name`, `-n` (optional): The name of the Code42 profile to use when executing this command. + +Usage: +```bash +code42 profile show +``` + +## list + +Show all existing stored profiles. + +Usage: +```bash +code42 profile list +``` + +## use + +Set a profile as the default. + +Arguments: +* `name`: The name of the profile to set as active. + +Usage: +```bash +code42 profile use +``` + +## reset-pw + +Change the stored password for a profile. + +Arguments: +* `--name`, `-n` (optional): The name of the Code42 profile to use when executing this command. + +Usage: +```bash +code42 profile reset-pw +``` + +## create + +Create profile settings. The first profile created will be the default. + +Arguments: +* `--name`, `-n`: The name of the code42cli profile to use when executing this command. +* `--server`, `-s`: The url and port of the Code42 server. +* `--username`, `-u`: The username of the Code42 API user. +* `--disable-ssl-errors` (optional): For development purposes, do not validate the SSL certificates of Code42 servers. + This is not recommended unless it is required. + +Usage: +```bash +code42 profile create --name --server --username +``` + +## update + +Update an existing profile. + +Arguments: +* `--name`, `-n`: The name of the code42cli profile to use when executing this command. +* `--server`, `-s`: The url and port of the Code42 server. +* `--username`, `-u`: The username of the Code42 API user. +* `--disable-ssl-errors` (optional): For development purposes, do not validate the SSL certificates of Code42 servers. + This is not recommended unless it is required. + +Usage: +```bash +code42 profile update +``` + +## delete + +Deletes a profile and its stored password (if any). + +Arguments: +* `name`: The name of the code42cli profile you wish to delete. + +Usage: +```bash +code42 profile delete +``` + +## delete-all + +Deletes all profiles and saved passwords (if any). + +Usage: +```bash +code42 profile delete-all +``` diff --git a/docs/commands/securitydata.md b/docs/commands/securitydata.md new file mode 100644 index 000000000..fa2e1d89e --- /dev/null +++ b/docs/commands/securitydata.md @@ -0,0 +1,78 @@ +# Security Data + +## Shared arguments + +Search args are shared between `print`, `write-to`, and `send-to` commands. + +* `--advanced-query` (optional): A raw JSON file events query. Useful for when the provided query parameters do not + satisfy your requirements. WARNING: Using advanced queries is incompatible with other query-building args. +* `-b`, `--begin` (required except for non-first runs in incremental mode): The beginning of the date range in which to + look for file events, can be a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where + the 'time' portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') or a short value + representing days (30d), hours (24h) or minutes (15m) from current time. +* `-e`, `--end` (optional): The end of the date range in which to look for file events, argument format options are the + same as `--begin`. +* `-t`, `--type` (optional): Limits events to those with given exposure types. Available choices= + ['SharedViaLink', 'SharedToDomain', 'ApplicationRead', 'CloudStorage', 'RemovableMedia', 'IsPublic'] +* `--c42-username` (optional): Limits events to endpoint events for these users. +* `--actor` (optional): Limits events to only those enacted by the cloud service user of the person who caused the event. +* `--md5` (optional): Limits events to file events where the file has one of these MD5 hashes. +* `--sha256` (optional): Limits events to file events where the file has one of these SHA256 hashes. +* `--source` (optional): Limits events to only those from one of these sources. Example=Gmail. +* `--file-name` (optional): Limits events to file events where the file has one of these names. +* `--file-path` (optional): Limits events to file events where the file is located at one of these paths. +* `--process-owner` (optional): Limits events to exposure events where one of these users owns the process behind the + exposure. +* `--tab-url` (optional): Limits events to be exposure events with one of these destination tab URLs. +* `--include-non-exposure` (optional): Get all events including non-exposure events. +* `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. +* `-i`, `--incremental` (optional): Only get file events that were not previously retrieved. + + +## print + +Print file events to stdout. + +Arguments: +* search args (note that begin date is often required). + +Usage: +```bash +code42 security-data print -b +``` + +## write-to + +Write file events to the file with the given name. + +Arguments: +* `output_file`: The name of the local file to send output to. +* search args (note that begin date is often required). + +Usage: +```bash +code42 security-data write-to -b 2020-03-01 +``` + +## send-to + +Send file events to the given server address. + +Arguments: +* `server`: The server address to send output to. +* `protocol` (optional): Protocol used to send logs to server. Available choices= [TCP, UDP]. +* search args (note that begin date is often required). + +Usage: +```bash +code42 security-data send-to +``` + +## clear-checkpoint + +Remove the saved file event checkpoint from 'incremental' (-i) mode. + +Usage: +```bash +code42 security-data clear-checkpoint +``` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..8e7f4ed3b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +from recommonmark.transform import AutoStructify + +import code42cli.__version__ as meta + +# -- Project information ----------------------------------------------------- + +project = u"code42cli" +copyright = u"2020, Code42 Software" +author = u"Code42 Software" + +# The short X.Y version +version = "code42cli v{}".format(meta.__version__) +# The full version, including alpha/beta/rc tags +release = "code42cli v{}".format(meta.__version__) + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "recommonmark"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +source_suffix = [".rst", ".md"] + +# The master toctree document. +master_doc = "index" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [u"_build", "Thumbs.db", ".DS_Store"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +html_favicon = "favicon.ico" + +html_logo = "logo.png" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +html_theme_options = {"style_nav_header_background": "#f0f0f0", "logo_only": True} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# These paths are either relative to html_static_path +# or fully qualified paths (eg. https://...) +html_css_files = ["custom.css"] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + +# At the bottom of conf.py +def setup(app): + app.add_config_value( + "recommonmark_config", + { + # 'url_resolver': lambda url: github_doc_root + url, 'auto_toc_tree_maxdepth': 2, + "enable_eval_rst": True + }, + True, + ) + app.add_transform(AutoStructify) + + +sys.path.insert(0, os.path.abspath("..")) diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..aae2460d6a39cfc5ab3380cb7920867609c76adb GIT binary patch literal 5430 zcmeHL%}N6?5T4qKf{F^F7tvLBxBh?^AHX8$RS*ic-E74}RTT9N^a*?pAHz2gR1mya zZ=#4v>!B4fPP#2_v!&VUY7sXu&1N!_nf)f2$rd029WXEe*lVyI126$V)12`s@=uUA zjXZor35Smz0P^K#5XM9DU~p%U=e79eW8YYs4w{6m8#cS~iPN>vk#U|#y^Ns#t!fS3 zF09JBwXtroj7onl`OsUF_tVSkiFfv+SCf(T1#Y!Dxi{@X)+_JDz7c*?q&SSaO8lzp zSK?=;3PXD4X|%=o{j2jJ$0&LR(3J=1r%1d?_$+v0PHrO4Xh;bpk&h}(Bp#{xi2J|7 zJ;cLv?!)+pwb-=>=%r5Bp`IzuY8$+JA;&u>)66hzx@B1G{0GlZ8SBL^BzaUyVXh&M z=oZ~UA4&oH@v3L(ipx;b$2g%&f%;&vgQf->Oo|u<^aYk+F>k=ye=E;4}*H>3-s_eIVZ&h2Ymr&U^NPF=&Ew)$hvHwvJXHd84 z1swAZ|5hEz0M8#c3C*=?KZtjC?TxZw(Ph6r1fGAL`rpp_}ZprIaWoB_Ptx0VIWkfYROF9nu|wh;*k&=b=N8M(J*lM!Gu>_1iqp z`>yYA_||&QS}r$d?K6Asxo57qX71S`3UVK@F-R}~0Kk@%5K{txr+okbX@80aezUY_ z5(EB+{z*d92>@8%JpO~Y=Lx%lUlKcuYd9<0n>o7~I+_9^CiX_Al#;fF=B7%fh9({k zy{3WyV6Z1CCZgg#ySE^%MB017yF3<=KG#Z+*@*Ouse)g_-|!E<3yRY?Q$-WfD&LF5 z0+}{UO0AORKNw|yKR17Fy?2p;M_7{MZYd^Q`@A5iTG;<+njp8nI?+4yFjquN0L1TR zJMnK@KB@|SdX`$bApgAt0Geoe-Vo`+c?#RW*W&t6uy*+&ps_%E+L*a4URvu+{=bje%$-~$2b}+%8 zYIS&@cf9dDqhb3OldtV+jPMCU&8CR_8(-b(XdVB(P6O^EBn(zHyM1zNX@HZbSNryD z4Gnj1(sdh`&XfIks8g2ZLIzM9x6naR({Jtv1vlCS1%{tCM^w7q+0bj(j! z`LT@3O4-Ps)+C; z{_gFg*v(Lhcu1x*uIaKVa(Jos$6IgCxbg3_qv2x)>tie2T&(vUqy6mnUyAX)?juEM zB_E}dCo-nE6=yhokoER(QacV%9YEF1H!hJnHj@0WEQ4B+uA2AlQ>DY|LEP=^13oU>E5N0y$A#a`BN>VFr~J1)GPLEe?%H|_T5rC894nPAj|~ zEK)rnP^I{A?qD`b(viX4l}Ql{=j1vQcsGJk%Fc3mhj~Zp4(*b?VT$IIoUM6H-c5BK znW$0DueNnlJs>o_-;s=6dcwozCKM+|3`o%_qWQl1w5LLtufJk_&go!Q!Uwz6bZOmC z!#er!LiXDCyIWtkvvnW4Y)MKYQj&^j5r>_EU(+_Q+038B+W>*M*6bvtt&CkQu+t1=C8Nvf@{zC$AiOy7Ye#MPschSbhxv zU2mdPAMM7bZP%_h4xhzVm1I zg>4H(cd1lIyHd9yt|g98cLG@`X8l9 zJ+{lErFM+n9a$!(Ublr%WgM}usR4xD8Pg0GWTC?Q5uyoJ>dZoKligPGjCOx1hRPcc zinJTeXI7o#URR&v%D(c`Bk_70R+{wd{MEjiuzu#rJKLqJ-NG+Pgevh6pvU-VR5}pH zeynCZijmVY+b0?A4I$i8`gVJY!r{=9lRl7FWkIDewJWRA3_@bi;4x=K$@s37yRcf$bwQ#++^UfGKf79s149(GTRBp)Rlbr^agAmdja!vx z>&)QMdZXRhB;KK99OOZlIHMnGlENv;S;EmJt}h`@>{yVEB^%bffLQg@%Jxu+XSfis zyxG6)*3}$RE(V#(NXzf;U9Kx$uB#q3o$2IH!X@Q(Ux-r)0}%-*%}O#$R1Jl87e(Y& zxS57`d3U@!yxg*>#8L;y{(HnpFy!3pEZ!zq!ox47eh(`GQ_#wx&>G95*CP2ki~}QI zh$hOZd*Tm1&gi0Z4iF0r2vkODxTwv%kDToIwMAM)FM6O7pJll^HmsGM0;;wmKy*5O z{I2Fsv%V^&;6BM39T>TVNTm9|Qs;@Qh&Acs9Gs^p@Peoj2O`d29MjRhkqwAvDSO!b zMKOlL4O2FrY(!k?zbgK)LI9X>zKuXdykUXtUf(aIX54F}P%zHyd+fBo&G-ho1CaU- zk{N*YK=dsaHKYABZk1E^53aGoym?^X+LH$^*H^$`MmL1|?|LWf1<2(+9{UG*FyL2sZmtdSvUI)_x?qbYJ8B>?N;&(H3%k2S= zn?&Iya~eO0xMJ$ePR6gFpoWQxXYj)Znu?95rgvx!&2lw?Ox4v1H!UQ!iyz+%+DA}#$ugXEC~Jt5zpTY>wT?JRm=T9OVjUo8!zWLBF0J8 z*zIpqa4?ehYV+3?WSj$O}ULZ1!12s9cYL5I?cXvA-mPbS4)`V*dK zQ4fGN>MmK>F|#%>@K~z0403W3Wp)goyz;Pmq{r4|UsQN7CoVM1Vpe>sV1vrXo9Csr zi7J&w#48cEo5XqFVEK8L@{4JT z***>z>Saf!#2Bf1t)>yTk&;Q;fq;MQS&mR~I4WRG(RdNAZOE|1LNKJ6=N_}uDm$OA z4enu^)f1}G*G*g_=HiP5;^}4i%W4EE5@V+y$cynW=Pc*_;Qas#v^1Q1ED6bfGD?3p zDl5@H$geJ+O}^;U5ZuRRwki5i6$0!dWrqH!1XL?ECPEKZRVR8Ov?3}|yrxnc>?bwy zO{O=(+P#oAsm?GnID30QF2-g~U%zt6w6k8}3vCX`_|KD#?km5h#DE!Wv3$%4Da|U@ zajRz^Q?q|v;YoV7_lZ3n-1R|eXYx+}pbZw9sTDaa=n8k8MJ;b7yk z`txgtXBtUw&&#vTTROuqUZ~YYjxLGq4wJU=haSSPsh(@F>id1_6H=Jdlh-fmj9-7! zw;p+ZsMK{oblrQ*GZ^E86>9yi#14)jg14#|Zi#RaY?vT}NsEnbZ*moR(fdN$l(4uV zO*&HiVTW-pVyXOPJ5$E+-wpRKa7_(d$G;>ix+oJE%=7&SS37>WbnP{CaDUlrM@R1J za5iSn9IEjgqHzf8K;o7q2wbJm=Fpt)|Joa z-`p>RX75I@N{AWLt7Bs?PoxqskDE}w50sVG(MlU)yDu~mp97(87QQ-~wE<{t_-K7m zc!dcN@}#5c-M@pfiD~Z@?0jS@_h$tuLj&9nS9VBEmBDOg%;VOFwdh2K?G;CiG2~?% z?2Du9%L4uBz~^|19vzumyW-~}2_+Ue+iEiJwA&#pc*DArroi%glQSAy<{v&5!i0{M zyrG04^gHQB<%+*j+txm}iWCpid_Qa^yA4?-p_d_joaY=RQNBJMMhAX7tD-J()F^HJ zXjBV3w0w%OShlB?Eq=kNS?3&u>I*8OT*U9ccisue#d!g?c$22AcqU~vl2kz&uk#DwH zwx(0FRXHp)7G%D{1wKfjfKe)RVj1>jxw)v%31Vgw1(mA0d1Oz#Exq!6@7lL2 z;85fvCw6Fw@S-I;mY9aBf1Mv_P`IwxDF9=gL3_!lY> z$E;6HHTkg|QO|-0V?cg^IT0^bC39*tl1TI=nVJ?-dM+2l3|x76lMahY=CUtcnrS>z zSbQ^Knr|#1XwEX?n%q}3k9jW4CT=e!d?C&Zhujuc!DKa=Wq9b&Bx4~Q=oYP(K_zAR zK<205^U5sZWy_*J9yTI5fB5n|Tf_HlX!=IpR(6hYiI^4grM_mo1~D2+5#E91VpC`G zITcqk+3o`tiRwi<8V7n0O{8xArT`Gn-=#``Y_Qr-u zY8`g~7UXo*O=Oj&Jp;NncJ_No>K-hc%86&^E+n9V8YGkJr>J0IPu>s~{wv_IWHafY zv_R^G!sa?p@5)W%pFi^h*{aPGx3{y`^J}mx#73 zh-V)!wSz-^rm0~4AiXR6mvKSUU&4&$(n;waYeG!$a5L+{5h4ON?Pad1oUE9A( zzemnBCDeGwFmv(?^G&Ja1xkeoFCAR}e2$(_-kJIHJ8l2j&aX98%*;~If5uvoD4jlb z)gSRnLfPX)QZrh-dnu?Czvf?SQ84zH6t?8AB8<(e=4K>ctzefTae1>-$+5`W^z+E= z>&Oc4M($gDp03oslgSOqNfOeX?)=u7W`E8tFpe)BCPFKgxC(EO^d?rV!!;lz_OsLN z2NQ>Pm&*cfaH1Kf?l_Aqq^8M_Mib3nr@MiJb#@x}XzQ{g?fC$0EUWSSS+)md2$V~l zIaxelKtg$~w57{DZb43GQ^cofoXY>V#Eao8i*b$c7M(7mGoPJSbDg_tq`@=(OC>r_ zgyks0<7-hsdtSaqAt#50VVPZL#xZON0ucsZm^ymXYJSGx#n^Pf5JFs zWLTL^JxUZi8@5xJN2bU-R}#TXoM?Jcyh1AFWam;Gy2c+GPpe9S5o*FheY;em(^Uy{ zrifU=@>O0bB(3bxVo+a)fUPubH$jn>tiOS94V9I-4k;$(G;k z;``+Xn~$FE6`*dkCL-c`tA5TlG+q5NS#jh?1y#?wX`WgMg zLQ1ZgeP`t-%cX>nDM5=U4U^9Zk1{1@VQV9!0p+n)kL1W-FZ0nkkBj=+SF?SFl)R3s zh`0*lvwVl#S3VlagbJ6+{*`*Tu*AJyKoPoIyWT7Cfd{7spmwH=F&2pDF-f-X(rJEG zja;X0K8>%WT}CxtAetkyLU$hVyzB*#7KTVnaVcAW3WmdRu$L2L}g(CaVSL%&Q8>&meGN^N5_73T5(A3zTyh;&sE~{LiuwagO%OuOU-%X zjGrAc`Mn%3yoNC9h4l)Es4dNeT6vnQ6ht`622bT)p8HHNUCoN$rW{<8XO!FfNU`fj z2vheHNqg!cu|&n0cX6y2W-z=eJ<2|m9w_wzIcBN@Sej4$6Ww%%j$qAm&+K=6(MEx; zuWQhRK+!1g`jE~`%9PW{YbzZGFc0Il-@DAS5q>4)rF;?iEc*e3WutdAwbt*L?#QJp z3p1YiY;=n&sg}2xvvPON9%_d`J-TyC1%m(9xHsle2iKl?u@{0JT0#6b#^mcN(($6% z1MgdS+NC-TlhsSKZ3b`Nmx~dr^{Pn=T2{@4gw(!y1yPg8mYG4u!JMcLLEXthN9Z4X z7afLs-FOn_Z8j!lFL4qEk~bH89pzzW;j{0$p+m6wRBkD%GqBDm88u(R#ZsWF%4bQQ z+ec~R-F?@-6e?iFOHOjwRo$OBg&IR&U2!Py?M9v1?kzOIVZlCZf0gvsSlm}eK`Qni z!}+_DQX#}S&cg&r8wT25PG85JsL}g-X`kE11A_yJ*lW`fKTV3`5`>#`o3ggUsV+(W zAz?KiUf=)ol_3)e=oPWyR?PR{_VHpA~dEXR7E(u-qHZ(iG!&No)Etk1M?&pLvD=g8i z*fIa=LULI`I&aUmRZ8@%tCDy}5H|KP^`GT=8_R6(TfJ6945hMPIn_OuD&$2N;hOF2 z<|_^Tv<8_F&SpfxgZcuss4qeJ2Gwg~|F;otZ|YA$CTo6~VJu|w(T{k8oO6(yjYaXR z>CFV+k3F%3=d!<>c5cj67EFfpEU^uKBVTN>Gw5Fz&!Iz+F(*#3_3T-c{KlvzeP@Hy zf{hhwh6PxT>-2F=xVqmP#e7N8_mR>54Gex$jlrsqTbbmt{94d?C7$pRm~F%4Iy&)n z!l&4>Mo*R=kajb_%b@A2knwYtqg$L`6<4Ck)&r4FBf2369~ldCqBWEyk-DeJ8z99L z9=!~)Rgls@A|B$TXJAGXZ^Zi9ffLj7TEvX6(n3LUv2j<12F=2xDV26mEHCA6x@5?8 zcXN?(ia9MHOMZI6G^uQ;c(-0X@e$38C;8kjp1vL0@#WHP)7fZw=zVUeSo4nA>zB(k zf#>vhJEe&ktdGhD}N`-f)ykG9P52 z^r-#s#j9j|&Rm}Qza&v3EEJ`---(!I5@{QtG@l@Jg9V?7P@hYq%ojI>392!d#xWaH=o4m0fC1&gbg~IpBTmG&F znE9>73g!3i`d1qNhT}8Mn<~;Z@E!b$t6q(_tw3vN?IOmrVP`Z#D!66`Re5B9Ea~Zi zHM&OXRpd-Qz8UzSgjnkKzFdmU z48GTW=*o5{7du&;e*fKo(>4gLhRIbH&n#aXYADSIkhHyRuc*5JRMLL&6J=nn*CAGn zSWhYz%UXHKtz8Nbvz>OoRX&Lq7pk3P)MQF> z8Y?pM9_HRVhuG&f*P-Ioy!Dl>GYMT=WnxkC!5;=RR+D+U5V3C!U3~tzzdn;^fymE# zTcuGeeocy|ZlBFFA-0c&;9yS~Lyl~-3Mj(Rv@MUsPSJi=!WN4AW`r_(O64#X9W5Is zV;$ZLtSHXsyoi#T54GHS=C9UWt~fgjCB%?o6A_lVdj@Fy%z@9poG{S6TfSPL49 zPH_{3Yw8YGK3pGsC@x$u{2MX!GM{A_jkc-Ll@k4Z26u$>g<~{z9INSCYP0%CX|og= zO_pikuQq7dw9xNO@Yrf1X?9fPyf^;Kc3jdhv-1K_%Rd4ivEZ}po7X@knD_AaF|eE@ zVzV5m`+-X$71Nf{laDq(*G}rJ30-k(n4z+UL)jE zsogGO#$l6Yq|rNRx|qj80WlyjUUV1PmymAL5??yh;$$A|EM2vBQ6|1ETD>?lywl6e z9rC)%_i_K)LQR}XA8QI$-FA2FJX*-16eZo1|8xxt?%u>qs?qJ^PqWadb*O26Pf1f_ zuEoJ9vr!rQl64iQF1Ry&Y@zaSg%gXjy>jTp-Dj_%zT!F1wi97 zE>q$AaYW0K5^h^z73!>$n0UH_k52wDMR)clbX-fkuAwM^IGB52a#nJ+-7lKHz3Co2 zNf_cn#>R0UW%xURuTW$7`3Is!d}j*BuG?~X;$l-{8y(649K7V(N}!H27Yq%Wx-w+w zh1$QlpW1xqc>X#{NcFB#)vx>wdRfidwMGS*3Qt%PP*K8RA3~s#RphhO&xPXx=O!m0 z&|@v=X56ei_--xX)aKBUntwj9?s?Qnug%60CI4f*SAlcHC=oFx$vMAsXy;>&+9&T>pQ=ZX~!!k?0guVz0!?9`{5VOV7#57~Cj*!t<8j zqzE~ANWwKnvn?beb+UH2QyLra!)T4{ceGapE9C*i$e}gbY_1c3NVo#tfX2{A zs9_>?+~IO9b}tk>caZ4yq>0Z+%vDztY$)lIOeD^QE5OnnN^-?ZWqbVqX2St(CDh4a zv4l`gH)QPn%L~li(=Nfz0>qWP&q4V(*}CTU&0?dy*fL|4loE(dyxK+U0U>Q7@E(;j9rQ#S0sQ_oF1#v@1s`)|6^y z>JQXpwp_~IB9Uu``5MPxTmatZA+j$dJk>vs#-$U!P|hjbO_5-%74zA zjIXCs8Mx)wh&uE@zj>ptinCV`0y)5yj82g~z*Ka2ICu1x4z6Drt+>C+@mGHJYc|@2 zc^B+W@6r5~$NUZ$4BSAcT`2zWey%-pGd{@A=-rmtq-g|CF?Ra388`~H8L*CUABbLp z>;Byq$LIjt%`0tZW)A%AIX83A0o2vye=bBxq02=roR2$qrS6UX&y%9BEf0zrk!QNA zh=gy!!?9;N4uBr-Mud5<*}uUNH@cCkdpl~i8a_TjIP89|X^m}~t2S5WzSrVkd2<4q-;eB#rkR{uT^<;s zy7~eIah!v&E<}+N=Gtgw$f5JWB&dmiVK4H0p$4_c#x)vo#+-P=((>!(ftxAhB%;tQ z?ameT1J$axV}H>BvqZ+&W|k)6XU$C?XpJv-zuwQSbiPA}4wH6c6}#G(|9rMECmciH zZd)oP{Zjpp*)*r@{h=uQG+5P)3FT2Xds>^LVw#&7Q%(UzL!1{d7sGlaK z(xkj)c=fj-CauMTf3yC&uIz^N&ek8Fz)T{TP~F+#K1xc~ly^*8Kik|%gy!mKVc!jQ z{^w#W@pscsi%N2;#K49Rs~!F#TKe}jF4tC@o_DW$`6-%y)g5WLloFePkMWvu48f%! zt2GF4B{lK3;}EfbFis~~epY&!-5rk5>E80@m(!SyHQ(WdKvfc0lIWvZ zk~7bdFX#zUcC}fA)B73u*mHc(&x|5bQr3HZQaG6Y2{5#>y>WRgvvpZz68#x+T#R!#cbdo~*oK;qzI=GIdHl5)3p^iO z*cxwTXd*@&pg<1~yWX|He^yE?Zsl9OWlpLOn@M-_vu_2tPT0`4Ng?skGN3gT)9cc9 z&ux0%E*LmRWU;t-A{H=Ug}^C?3YLH7@zQ_^_8nvAt24609FCb03i{A@%@HbDcz|z= z9tMMt-cDHQHwLlth7?hq#5q+Hd~1zCBD1t(BD0wLMLqDuAr^&R3#@iemVx22uN4GT zY@_m*Bf&dn21v^iJP%}OQzqW6Er?E!7w+9pNWaP`)8q;55*>#^E^Iwd( zQn0*x)=B;6LnKGfq^Zd9mUhDC+R4WLNfV6Yn0dO7$-aXNI0$#~Qf+avtLEn_cyD;O zpz-E_?JDX{;h`R^?xtO3KdQF$-iM9-xQAuS)SGm@ALIKWwmD5YF%k>0&fh#&{raxv zgK)bOBA)i6!D}VeV$8eel!5Mq!`(PtY5;!b+4V8zqz6MyE2-0%AM0{dsLSTkDveNS zgX`<}!$neLfz)DPH)j+riELAnKn*-FRVv@?|)P z%RJF;$6=2Fsta0gVZs#es@C8oBC>`=2c9Wu zZ6~M<4E`3rX-bkm!+9f_zu7qMjtiJmD&{jkc0S{^zHDrpyUQ{1!5Y_U@8;TQI`8uJ zkp}3RxxHevDd<=dkrWb>lV<6!Zd-S!+XH_cFAf90O3abkn7L}{??5{LP6kbNWJ$#KS`NETnrIZ&RHP|Cc)qdiMvJ$Mp1BsI zFz+@+rMC(v^_jWM6CSn&8-kIm#F3x_UsqEh13+vXsqE~Ej|!d0Kvd-qkoX9I;v zxMq|Bpr3F|pe%bKvye5p72nqClKiR~f%C@C)k-g>w%^qe)#?WX9`8Ju=%Wj)b(u zi>2J^y2;VK83cySM9`;r{G%ji(`t2665rsgr+3c`BXv1htU7C-bpY&vcX z@}*|B;NnK#Y%D?$7ssBj9qn$U0ZM#wjG?d#5jlwmY)Pn1>Yb+N9RG z_`LF)m}->R3V7EXS6cAd{PX%47*u7Dd)h7*fA985<7KF*qhg$?;R)^>{v-*3UPFnW zVaDSQw}73H6wsVdXFd@!t(;_RPJs!oe86}yT*M=&(x9syQKqxziXHj zBPnXegNqN=JQ_?=iH3umqVetrj6soBAJ??)5@U8xmNoFCVU^qQKjN~aLvEb5Jy~fK ziWre$qPe9aOD`9(f*A#oQDf;(f9*c9t2nwj*58g-dcUpEPTx5oKM(GB9m?LfCa9O! z`83m}HXb>XL2Aiy+Zt|wt`M$5pp-qZ+V#koXyxC?0xjAjMtm$>*t&8Sd3v;)hI2F| zEYtxdq5A4Um(c|@G&6a~+WGcj>WL)%n zMu-syIvNOpYDk-ZCef%%=S4wG2VLj`!;2Kqxlr6p{1|FwNAAysaL={3fK(jfAa)^x zR+08H5(f8#FbAX3Y1jpqY5UoHd)bz{s_+aiY)SR!pHs&?841Yveo`5f?+3U8Bk)R_ zj{Up;WH11Odjv%u;aj(o42D@a7lc`U(-~LfOiQ|LZ0+Q&t8;^3oCq0|)t_#yrmzHo z6peKr0%!z|F%@~AR6SM5=6ZSj0qkLe^xSuxcINnkpLt2nr)dgnMj@zJlUV_IC7M^v zW5stci>g2IkX&I#>6t&l+@b*38d2vBf2sWhw4Q>f>`{{gh&2OXAM{NrApMZuD-*oF zy*|5*qy-*zmgqGd9kT|WZZ*Wing&~%-Hw{A9@HX9&pn#t5?TR^!Wy3=FvdP@d7$DW zu)bZjX3>~I8*VUb_4@jQXo1Z2hLp5m4Eqs1KI%Sib;s1i-gRG9A4oDuh&50mVN-tw zX2%DuD8IpWNM}gyg%o$G=7NAaqarOEZ9BxGk*q4Cd{=JzZyt(+MZ^czH@2~h_f37v zDtqY9)-IS6>5)eaz%Ug!@16_zPRaP|RRf;?d+g#WJQ{sLp*VWu=g$CH;`vJ`A3;bl z<+Bh`g)R5D2`x{EOdZL={(S1(VYn31#~_~ewtg#E*0BE=9?hnU{W=GT6CAIf!M?k2 z@5MdQWTlDj^YnW;4rpW-=2j^lUF%N{&%LRGD5RbR3@JzttS^eCEY(QA zZ2|Gpco2YvfxpN3p9dfKkWRk*z)BarqBU1%qO3^krwuBu_4y`Tb;qI%;YE6U?elNY zpr0mFCFabqKX1lIR@?U>5QG`DC1Av?nM^MGz&S!k%A%BvFtCh*n56hjiZNrL6+#2Q*?955^Rq)|h#Y|fZjJR3 zHej3B{%0(x|LMv|s$^!jlXJ5t>zn8qPEA!}f3%rJfi3lY`3~=pzq6l~ zEF3%`P*fsoBtVNbSjJ-5*rJY%Fo3#mnXf^Qu@9(!7YEu1#wnIiF2$YFB4rNyd@Q`d z9IRzzr%&N!2BKWI1|BeS^4a$pvw~>jdPQqF?-z%CPlnG}x1YdNF|?-AP(}+gCNqWF z`ILnru01!vIE$T<8^VZs80Plg6PAka6$3f-7zR+I!MfhWq9E$^# zOeo!fEcfK)c2-H8Nre#shH1a~oOM6=f1vm2&&vsi|AY+S-KduNr2kWX+z>$x5X1I8 z`O?0;i_MyBAWjQJ@tnOU^_v7kQ zNzmE-X}8QiJ;PPcT5v1`UO$TotfU2?v7Oa?s+@qq`LSEUy}R}&ypD@MkNPUiA5K$% zhk#)^0}Vgxz%_%|1OQFnFrJTKtzr%Ro|10E0q2+NRpPfBQh{P{xzCL!azISW7_G)% zF8Z}pPTkA=$6)FKY(fDeDBvOu*Puu%&lMz_kbPZB(E<0b?TEi1LCm3QOPK@cKU1d! z2?>0tz8?l5oHB>dzjkf}r7hKk8^ZNR0uM+b1{aA7K-3=X!ie9(_Ma**Gdh{#ST1(J z2y5dSJjyWvVZVL?1E_E{mIX>_4RL^FUohQhl#bQuARgVzl@zFtqpntPQjJ3g4FV9h z0pu1iG7?Kvi!VH>z^_hTh<{3+_;~D1-1PZh*GIPxxr{X-ED%1le|(vvq*1gmoCjd% zo02qXl&~gl+2R6n$ zr20qjRRcwCd-;$FZSqLPTef0?Gu+m0dFYvw$P;C10N@_YmF7Ol|$a&Q`*y-@fq7$IRGMF5?whpVeq&Nx7} zwDQwk(ybXd^7)^SLFbrSTb@1Ngc}`@(&a7*?LXbVU2{K+6^4O33jl3MdA;nQ(o+9} zf`;BZZk-*{d>SnhJWxpntU+87S@{wR7qHMof zEVg6pZ5kty9xczHSkglTgG1}!BU0Dig9F#m)heHl3PDoZhzE`gdv(;df+5j=a(=sZ zW%I)CaWW5FqvWCmC>-jhLGj3HlE9V4%mF>UAZ5zDzbc_g+-T4;h+Vgztv(06_EB_u zt}p=LJSuC;zw~k*WvTeP*lYhZqnnERztgzVKciWa@~Qi3mcG(H-VOj3L7+47>#V(} zYva!=J^OH8S;U>VGoGz41x^w_-q7ag44PT_G5Fwnr61Ua)C-Y$v^c@za0kDv%1Z6U3Ivy26%5< zKNt%&vw5H%?jvACFpE+5HZfk^3a4d|k@_K*f<*bbq+dZ$_nGQ8xb4T#dxH=taq^z4 zrxpa5lA7YJNp2nmt*Mc}-yEoSSE>IAPe&cT4R~~%8r~f%N|%UHYoxR6;ylRSAOX87 zZq6TEHkCvHVEnEb0#Neg(S@8f=OWc;&I;M9P<+=ireWRL>@rC4`y2}Oa0lZVX!F-) zGVq>zx}^JIIWe6Up!Y3#3nHM#9SK`sJ)S>d_py7Cxen+K3B`!Nniu*Y;lo+{h3s$U zoCpcW^p^&s0QMKmI#U$Es5~OnF_#PIu&`D4jtWH|&?Sn!>`JmRQXc0oI~95%v-?tb zus5Kk4bnz<-z2?Qo-yjri+!p7q*e!M)(XT3qpMA+*a1fI*a0a$7&w|^o>hPjSk!A| z+Ih{q4}p%@@yX1)=zf^*1|o6<1z=eqr!<=TR)@;-yAzy^j1hoX7~V$K-&Z5tbGX%Z zq7d~CweVIMabh47-BBa6b{)OK4&1Z|_9^gH5mV`~63{+mAoU>ZI!xY-3Z;!TQQPeY z1$|2J%L2jkDjNyhz;F=E-pdO@93Pt;{c9?$y0ZTD*gM8KAYxa*OF03;zkY}6JadtD z77+;ZPX|=qu5$m?jB?c^t$Y}LwN|D-(x@v8CT!}x2g9X+p z(>uvrwq9I|uo~4xP17eyF5jrZW_V#sr;oz{$A4=y3B3rX zU+vm0RFXEPjYw%W?GxJnc6W**TxxxHjM5{cjyLj2JzZPSpaeaghrC!A4cv%J#AB!@ zcIvB<7Bc zjIt~Ds22VzUGJU{3PQ}nZkpkH;?u>RIzDT1010_<REBwOYKZR4xB zP@qCDy>rBFnmA2#+O)&Txs(-5;??F=p-`Zjn9%p$rw$k~d1RW4!O8ms-Rqa%wDdZv0UWGZzF7nEU@Q<#fH21Mn{F-&iRIgPf$GfM zYN_&StjQnyGHErLP~;IaKAE<@55caa>ibJ$6UqK>`wbm_BSE4kIr(yI9U+NSwyO>r zpYX&?r#^!oZ4vIP>zAgwpK)ki{eiX@d^yi!8^mHQW_~Gq&Mogt8Cp?mP?cbH4n^(m zEU;>vOV3z&8l6H$yV?qJ8hH|s!O(w0-70Mm)*s_@Teui}HJy^QK6OVFvYR7Ckm3?W z=P6st4;Dmhq~I7>ofAK?H3pzXpTtw)RfPQPo9Hes9;6GU=D20>^C5Y(@<|4xCJ@zq z^Ft~jz-`UO=+IU~2Z4Uhnf)^+%~d{PkXx^q ziI#~!R)=`cgHzdQd7bzy|M`4J4g;%1GzI^fAKfvDfQ60?xf-!bnSFg1-GA2ltOJ~j zNn}KWN&Q@3eLrvWb&JbXDgWoDRRC{|8ScfWrLyiz{zz{phR@)%eQo>G znf7hDE)!_>Rvji}2Lo4dZGsjwy=^Btrk|dbD;1DE51v|&=$H6DF+6CO&@GmTYD!sW zehii@zv>=HPK9boku2nN4yGw`KuBunY_bAn1%TzAO>U640iP>a^iA@{oo>~~k7#K! zJKG<=`X=gbSGL%bhq7b->Fp^R$g@^R+U~&i=ty|WkUn zTy0JweOy5ct$*kDJy)<*lAq|GM*>zx_Eg^&)*tIF>b+6gW|^V(DQTG8umK15vM;hM z=a;^g9;Mt9RDiUE|03NfxTwG-{g4anBY(Y#XQ{rCC z@=D|}Z(~<5Up%*0_ArFWFxl9kRY=rc02=y!0(x>0`-dtqmS5SxrQX%vud2rPCZYOh zG=ZwEOJO$D$6sAAf4%6++Le(j*bH3}(oMlk!Yw>WO7ZaMlmfQA459iv(mc*!-N}^_y4?5}A{KpDoj}m>=Y0 zz`@M*dnX*q7p3m!=?D8e++3+n6gw2mrEM#;s*hhIj4538jar z%U(XWz96j}_-6vT>0k#hvz$I&Ti@E*Mz%Re%{YDB>9;S<>HQ&_x%ZeAxIR9w!R(8r z&B85bqq-y@%r|6KLaEpVymAk)O)~hY6moc&k`^gO{Maw)dAM&XTkZP0{$=HylRqh@ zA023G_-j?tRJX4QmM^wW;K=6~){#+gDxhW;-|NvN^}`cJru7mW$W)qj-7a;9 zfIYPA;S5=ZtF%R2RPZg7J1g+v!%3dr&n2Ntjsjqjd%bIobo4HOki^uPm9Plhtb&OB zW@Lw_!yk{4G|GIzrrbYAB%s2oR?8|^7^G=8_37YXF|y0@3or(}gH1~R?EChpJjz zPtvS!*!GZK<&4h zbOin`v>Bv!dw5$keewUme%UQJdqIDC0nYc%hC}(2TKdsfMo2ov0^v}i9M<4IOofX0 zZ>i&)7yLAs$Us}w%^0|#^uJ$SisL!JJ|5Htbl-P_fK!|a+HS)5pYacj6zJbX5!})0 zNZUQ@U$Q$-xeIvoJGlTL(a!5|xuR-PY1|te-HnujE=lnjhCtW1tdWuzOzQCLgL;jE zxce$-yqT`n81Az zw|V!_eckKez&0Zp&(M7j4sK3jv2)t%igJ1nZs{ye1p22-W9%#9Jnug5PPoTrR?!zm z?b)hN9bdb50|x{EW@*pQOtoho#oQ!sHs0lg4pel+CDAJd=iGu+CGdIJdZuj;+HWm?G-;(^OBsZ!i(4y&;2~|xE z-qtr0pxGwQne3BlXZZ6@`1?L9kru8#C9cm^C?VU#eta3eLcy(8E1U0LP?&GAD9WCO z2Mop&EG~$k3^%bw;)G(qtu`_T+AKlvN@?hBno%?yS|aMRSeybG?PrNN%&Pg5 zCW7r*1MY-)4}5Y6=~xJ`oaeJnM30ow866R&;($9Pj!VuDwhug2aose>mKa456ttl5#RS;6_F7#_Z* z0tG2?`gBm~8fc@9Q1AsWhfvX5SNP2>=6g!oIo`qfxgyGgd{CEmPfzrHN*AieGbj?Ygyd z@rzp?jQb%oWg|!~PuQx)!2{Z#Zs%=A{RCgtzV)fq+alLx5CVo-J2#Boz85YF6qWn9 z@dW)wLpI6V+qwlTb`Zc|>fT|%IEt~6i%W7q^78yXW47x<9u?)Bih5~uukCT&B)QVLvUuHF zS>J394xzjAfCPmL8I?q>?~h1vE)RLDxx3Z(bC~fm#_6Bm@IJBPnAJ8XPFdGBs#ZF_ zi|Sxj7W~P&*`gC)0H{6-GcL|^ci79BvWS-lT~@mYS9q9G5iQU-G(NU{(F)xn z&k#I$)yqa^BdX|f+Hd8sD2*y3)JzYr`%L=FO~XL2&%yB8`tFXR@sENG^RAi)UAK>7 z;w!+PxciEOur_^)?3n8K(8X_2+MW$O2QL}~-%-dUrWcuYv@E2_{OKrnK$gGHXmH>~ z=hKyW?sx9P6@NzgY;Zf_{smh?x}oTj7iFM^tJ=EmXV+yZumK^~%8Lm1Q*|}7h%G-K z1%aL1GBha;oUS;eYkXy2r5uoUNff-7S6AO^;^|r0y4$9@K^!>MIQ+A4gVNBRGau@Vyp*9N#JA{DtxIrB)7y5&20}14U~NbZrHk zc_}l5*FrSmhllf^e5JKpwaZ3N%Bodd&fT`3_wLM+08TGgIPQ(V&8b~m03Z~QMU)@OH*bb|GRUC^ptBBB!+&|MD$_*(cWX>g~ zNt<ZE|b7vfJ2;}T<~t+Oh{10VRZh@UlCf%- ztwLr(-He{%q?%HAV&)LpjaI=nLwDu+CtPUhbX`#6CpKAiS58j&&CJrfu&iWX)o>O^ zzse8XV6)`lA_zxW-(A_7>w`+njFjrR=A&e>UY9Bk(2?OuEVM|2+&#Cq7vlz~b~pz5 zY!w-V=$xyCa&HXHe2ezBlfva4k_mDCWx3<`s_?ZsxW>ajLaw{OSTd0!5`X~gUc$V! zTA|mSQ3*eYQr{D#5P+^>yeQ);u$Z1RsboqmsUKa01n>J2b)K1bXyw#jA@r8;YO~XT z7gvgAKu~@taQYXI%BvMUO9zkSV+J@a@^yFRG(%)U1^bcs;{ud~f2sS4{fw;xH&STw zqdY;%WGsEV@}jJ(fX^T-EP`>SBDzrovgw`+N4BIC&{&~V`!m3aub@gRbtuKZVaJ?G zo3lATtR{AMVH4;%MF*Poe9S9XScNg*ZxmEW7bY|AQ^4Hd6vV=bmd_P8wipvq@I@?mAEE#V13 z-+)8Lwj`c?MOr%g6yC%2MU2>t0Pp{`bLIa~f8T$s5n8CJA){=SoebWxCJ`n?jC~!% zAkkvW8igqPmKpolw;GHrEll9*=oEUgzHXI_G)L zx%a%zJxqM9Qtwp#u`OzcRf&NQbLy`*o_SFCrbZ&ZTyMnsF%h@!{A%lMXi7d?VOldj z-M_djU_4h$9?n*@wS1H%&zcZo{2e!iwCo|;y$Wj_U~8~MgKrEN5` zb6r1$nbJl`U6F28J&_W{(BHnGRr zmx>ChB}$4kPy~$LzDM&yR8scG=Z@Wq-3AdW^eB^URv@Irww5ON1IBAIQ|T!$8Kh~K zaZ$^xX|MUX0KHD#NVdKuCun*E;bdb5j%Ewl{?_o|6Eimo)iCQJF^&FSa zkIT%n9=#jefsf^8X|8*(>2Lb=s>Z_~nw2_osyOBBILi9*lY8iTb1KFF|-mZwB}l94?^O9K6kLn>!b>n2G;p$Aaro z4Gz|C`CB1Ra@9*CgYS5!id0Sga+Jiou6uSS>L@3?liU1Dfborqh>*x_!Slm6lq4~x z&d;*Rf^#Gm`ek}Zpe?mAggR9QO4B>kvslFiOv2v3;ipsJ%|MQ`$d8|=e`{LowvUq; z^MgCCnhPu02)aI6&BYlUB*zMU<%1%32LuFKzdW0;Gv*oMY5c`ENg)NNO%(k?K_JZ= z0d3a5Os5Q4RU1`)2?d(VDs=7KOUu=9q*;;Yx@l+#f({}=WTIg_j)8U)WXyjs)1y&|SV!NG^-AOkh1*IolY$5Q-gjA_ ze!%l#g%5W4UjNp(uiKp(-^KRLCY!K?9P4j*fBRr(YU`k)ho^D$cvsxAvwo6axjOSk zp_^;Mk`Uj;^-obFt1k0dM_Ft-CU7OFLSrKblSCI2+l|#P+!a(Rhva65s1Lk%Mj%Uq>T9BRMTw9(v25t09I;pFY~+~Kd$dvkc|L?9zZY8q zQ7lJ8Gq;svD7vo`T|IxOwm~gdI_S%p&gEa$H&9A`*;vEtm5suQkO8 z5EK*Chxu6Rrd;Q;J7xMHA`mm!W3uKBvG(haYbmWEqN86jZ4s zjQgtd0~xNg6AihTLB#D&Td^kIS9jO>_dN|fMv>NMhBM<&on}d#mFcRUGITe7CrhBM zxk~V9oo(JV+{&{0z+;)TMZ*7atu-=?w7s->WuAO~Onw$u_5PPht&y@<$pI1@N9Jjg z-?u+rD6949!x)ds3V1KA6$jrC8zuOOE{BE=&-@;Lr`VCN#}Y5P36eMpccyt9rcC>W zp5)erFf1@!HB0-F-hh9~sWiPl|8?*|J_-`G@OFsqV+9k{_gSM~)L*9yO|?WLy6dLw zKYBNa%{M?si|=LkVa7Z67pWukD4wl5SGN3D4U5cIz(~y}MPWRl*)y+h`2M0U>AH(SY?|%E)+C-jP|7%!Dq= zT?Z25Ex%FmU5`1`DbB~BZ-Vgo^P9BQEd%IQOuBABulkOns`kCg5SUjKsRJGqjkL_Y>Q;!(nrbCPjQ~W_@}@dZdVvd2zIa zt;6pt$u6kg_j{JUGp!VnG_v%HNH1KbeLCvpLe6&j@T2F4R@=~g>e$kNGi_6C6fI+QU~$r*SG~octjV-F%VmB};x?4Canod9{?=gP zRp+PoBKtifd-$+cPpIjsms`AR@TvxgxuQ}!U+)XETQ*({FvwJM zthpac(C#pTExBYSPg5#0%dZ^Cm${8B@Xf*h2|@NnN%FgvF_qlN4v)%a2>B8)$0YQ` z?+(mJd!*Em`ocPLv?oi1$-S4cOZaf;`Su!%Uwhw{BE-6vsf~>A znA*aCHRONilbE9QQ@aTfLR^B$ZUuF{#F4rVJ{I+PiT*gi4lZRq+S^fchL&FTZwNAy zC%t3o`6Riu?$XJv1T_S!feS)0cDr3xT22m9#sNN!$*5oC^1jT>g;@=lX@`w!mRZ|M zUJ&9%)3~N)*xBvQGY?L>->u>nf;6?9iq9;vwfWUOICfF&Q;aLA&kCKEn{8ildafq2 zH_pS`7DXu{d7Io4S!?Mni<1(o`+zbbwy^dlso&StQSR8?+f|fdV<}Lj^^IBo(6@wb z_oYsiP~x~ZkfV3hoLsM%CFzp9@|WPv=@Cf*cb{mgsUD*pMRH_yuU3U-IP zL|tSuidvqF>~50;ziL$PIS(hf;FV@BoH;z~u>asy?1N#ji1=O1r9U8@cMh8Lf z#nkBU5rH~zjh$&}gzk^ytq`u<5j;F^rUgNkI}@uPf>R5%4{QMJfQ?QSCZkI4&;>mF zeRy`yM5sXPdfi)+T;rY<`)eAR^ykhs6Zf9!Mj=Bu>A8R3)?}Qk-2%Tr?4tDO0sijw zr%4+04$I1re^C^OGhQZA} zSksOIz=WQOUYFxZ?;P=aikpMSoy?ZXUG`jmyk!MS0E=?WLE0}HRo)+vdY_CDYz$ZuY=st_%U z;kh$cALE}tPhSS;sp>dZrP>VMUuid;r2F?c{knQ#sW*=WRt=8*&~lo|Gn+_OUf&k= zHiiATgAQ6QNx{+HkQyygHunC4C~kry*n!+dON0(s-WJKXyM3kjky(mda4qx3-;_ch61let2p36(;(>wFh*3@ROuGbMu)M1xpo)1eWn?fYaYJe! zubOQh!n?YcHmHK6oy!?;p4Vdo;F*J~!fNKdor#@aGzH{5Om`xu3%z;azNB@`@TJ3nH(uQ$A z75>zAt}yCP-tykUa4B#f?R(=V8rN2aSG9}weSde0ghIS-)-pGM2_)|13d1%uL+!($ zIldMlyyevi^nV6ec?>vWE1Fn0ey0i%zow@h2Oi0dBR;4()hhX^H}()mkAVz8j=a#@ z{gB(0Y{&O)%mO?P3g!tEGwAr;AflJkpeLhh3Q%;{-_1Vo?{<@zG zpV!x-o=2gbrNH}cd>qcHI!jhQeRKb6X?|!lKn!)>*8n&Qh2NojAeQhqMqNe*Nb?oz zi~L$NeO+33@MUt;tyDGJPoZn-Sk(=Un}J?AEOg}0Lyyv3C~peDd~>qgpa>{&adN|+ zb9m%ul6wcE3%i9n;kkVwSY@AA(vFkNT%g=Z*L$=Z$hyv+f0&eyn2h+{ih*%{`Se3> zUhMO)z0~e{yIwv2{gj!{5)@pvpP%b3RLII8TqGn zSqj_9q`h%9=Fl{L#)wV6E&Pyw6pbp88z+3{!#Ps)m8Xe_Ge_QOK;cM`E_%=+*XN%q zKXiWnbi%4gxq^E{qHzzwyjWrG@vL5mrPH97W~0d}*G;{~-4-NUAu@4Ct>XIa-Gt*u z{i5<0v1w;Bu0WsMfI;)~vnrZ7>Q*k7i0l&ZJ zeC*-#umRw#Rs3Z8_Q&E?rf=#qU$dl77At!+EY5IB>maRfS;yp4QO@7K7(x^aOh_Jz zZbydl>k?2TnFd`!#d|%GYnyVc`lRTug**ov7Wo~|Dcu@gVjglrn~i;7D$~mjLj9=* z_HP_VPCyR}JUB$<$(P4E-cf7q*t_eY!hW5z3eP%c+X=5(S&CIF*hY-tKv5_0>K+Xk z7Jxat@wcEAbWCMo44s4Me~y+U{r`WVvOk&MJY$xfsHaYEoCgi6&bz0 zve{!u4xw_e#A!%AWCO?97>B$qUi`U0|AEZyN{s1vO`asD60hqsVB<->`@N+`{L9gKm;y zZi7HDcryzJLQj&|NrHnZ_Nly2x>rnQG}Y6;G>HoKouPmFS9;C4iBjB6siom!y-C&` zxjvvAsY&RF*$@{h#e2mQMYshM$V|ESQWV)`P?g)7JCEz3UgH_!NHwXKx!@_5bF5g8 z-ZpDSr_IAuo(E{A$@FjTyimjtxx>gAT4FZI#{mzF)XdV^i3r-|Bq=zrXFqz_kf# z&I9W1JHR3MoymvbaGT1wo!_%$gSv&G*iTUN9_z~o16a{PO#@~^eAPB*$6fRa)QBx$ zA&@3T(0g3(I*51ql@GXsQf&Te!RIdMU_>97wA`#_L}{~e8UZ#Jy}k@>1k(`*Tvv}V zaEQ6BiDTq=HzwhC*2<$x2v;vVox4?6N(|_{st+(pL%j_LnIz@@*$shsS>%l)xIxMv z0JQYo(PK=scBAoK=x16u(evf8z-hmfF*pb(291gXFF4O&mt47>V=VB>uBQZbW@2dn zVdeanwcpNUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/userguides/detectionlists.md b/docs/userguides/detectionlists.md new file mode 100644 index 000000000..752fce259 --- /dev/null +++ b/docs/userguides/detectionlists.md @@ -0,0 +1,50 @@ +# Managing Detection List Users + +Use the departing-employee commands to add or remove employees on that that list, or update the details for a user. To +see a list of all the users currently in your organization, you can export a list from the +[Users action menu](https://support.code42.com/Administrator/Cloud/Administration_console_reference/Users_reference#Action_menu). + +## Get CSV template +To add multiple users to the Departing Employees list: + +1. Generate a CSV template. Below is an example command for generating a template to use to add employees to the Departing +Employees list. Once generated, the CSV file is saved to your current working directory. + +```bash +code42 departing-employee bulk generate-template add +``` + +2. Use the CSV template to enter the employees' information. Only the Code42 username is required. If added, +the departure date must be in yyyy-MM-dd format. Note: you are only able to add departure dates during the `add` +operation. If you don't include `--departure-date`, you can only add one later by removing and then re-adding the +employee. + +3. Save the CSV file. + +## Add users to the Departing Employees list + +Once you have entered the employees' information in the CSV file, use the bulk add command with the CSV file path to +add multiple users at once. For example: + +```bash +code42 departing-employee bulk add /Users/astrid.ludwig/add_departing_employee.csv +``` + +## Remove users +You can remove one or more users from the High Risk Employees list. Use `code42 departing-employee remove` to remove a +single user. + +To remove multiple users at once: + +1. Create a CSV file with one username per line. + +2. Save the file to your current working directory. + +3. Use the bulk remove command. For example: + +```bash +code42 high-risk-employee bulk remove /Users/matt.allen/remove_high_risk_employee.csv +``` + +Learn more about the [Departing Employee](../commands/departingemployee.md) and the +[High Risk Employee](../commands/highriskemployee.md) commands. diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md new file mode 100644 index 000000000..863c2a77f --- /dev/null +++ b/docs/userguides/gettingstarted.md @@ -0,0 +1,108 @@ +# Getting started with the code42cli + +* [Licensing](#licensing) +* [Installation](#installation) +* [Authentication](#authentication) +* [Troubleshooting and Support](#troubleshooting-and-support) + +## Licensing + +This project uses the [MIT License](https://github.com/code42/c42sec/blob/master/LICENSE.md). + +## Installation + +You can install the Code42 CLI from PyPI, from source, or from distribution. + +### From PyPI + +The easiest and most common way is to use `pip`: + +```bash +python3 -m pip install code42cli +``` + +To install a previous version of the Code42 CLI via `pip`, add the version number. For example, to install version +0.4.1, you would enter: + +```bash +python3 -m pip install code42cli==0.5.3 +``` + +Visit the [project history](https://pypi.org/project/code42cli/#history) on PyPI to see all published versions. + +### From source + +Alternatively, you can install the Code42 CLI directly from [source code](https://github.com/code42/c42sec): + +```bash +git clone https://github.com/code42/c42sec.git +``` + +When it finishes downloading, from the root project directory, run: + +```bash +python setup.py install +``` + +### From distribution + +If you want create a `.tar` ball for installing elsewhere, run this command from the project's root directory: + +```bash +python setup.py sdist +``` + +After it finishes building, the `.tar` ball will be located in the newly created `dist` directory. To install it, enter: + +```bash +python3 -m pip install code42cli-[VERSION].tar.gz +``` + +## Updates + +To update the CLI, use pip's `--upgrade` flag. + +```bash +python3 -m pip install code42cli --upgrade +``` + +## Authentication + +```eval_rst +.. important:: the Code42 CLI currently only supports token-based authentication. +``` + +To use the CLI, you must provide your credentials (basic authentication). The CLI uses keyring when storing passwords. +If you choose not to store your password in the CLI, you have to enter it for each command that requires a connection. + +The Code42 CLI currently does **not** support SSO login providers or any other identity providers such as Active +Directory or Okta. + +To learn more about authenticating in the CLI, follow the [profile guide](profile.md). + +## Troubleshooting and support + +### Debug mode + +Debug mode may be useful if you are trying to determine if you are experiencing permissions issues. When debug mode is +on, the CLI logs HTTP request data to the console. Use the `-d` flag to enable debug mode for a particular command. +`-d` can appear anywhere in the command chain: + +```bash +code42 -d +``` + +### File an issue on GitHub + +If you are experiencing an issue with the Code42 CLI, you can create a *New issue* at the +[project repository](https://github.com/code42/c42sec/issues). See the Github +[guide on creating an issue](https://help.github.com/en/github/managing-your-work-on-github/creating-an-issue) for more information. + +### Contact Code42 Support + +If you don't have a GitHub account and are experiencing issues, contact +[Code42 support](https://support.code42.com/). + +## What's next? + +Learn how to [Set up a profile](profile.md). diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md new file mode 100644 index 000000000..eca2d0b30 --- /dev/null +++ b/docs/userguides/profile.md @@ -0,0 +1,35 @@ +# Configure profile + +Use the [code42 profile](../commands/profile.md) set of commands to establish the Code42 environment you're working +within and your user information. + +First, create your profile: +```bash +code42 profile create --name MY_FIRST_PROFILE --server example.authority.com --username security.admin@example.com +``` + +Your profile contains the necessary properties for logging into Code42 servers. After running `code42 profile create`, +the program prompts you about storing a password. If you agree, you are then prompted to input your password. + +Your password is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that a +password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each +time you run a command. + +You can add multiple profiles with different names and the change the default profile with the `use` command: + +```bash +code42 profile use MY_SECOND_PROFILE +``` + +When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile +instead of the default one. For example, + +```bash +code42 security-data print -b 2020-02-02 --profile MY_SECOND_PROFILE +``` + +To see all your profiles, do: + +```bash +code42 profile list +``` diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md new file mode 100644 index 000000000..1c719daaf --- /dev/null +++ b/docs/userguides/siemexample.md @@ -0,0 +1,204 @@ +# Integrating with SIEM Tools + +The Code42 command-line interface (CLI) tool offers a way to interact with your Code42 environment without using the +Code42 console or making API calls directly. This article provides instructions on using the CLI to extract Code42 data +for use in a security information and event management (SIEM) tool like LogRhythm, Sumo Logic, or IBM QRadar. + +You can also use the Code42 CLI to bulk-add or remove users from the High Risk Employees list or Departing Employees +list. For more information, see Manage detection list users with the Code42 command-line interface. + +## Considerations + +To integrate with a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration +must be assigned roles that provide the necessary permissions. We recommend you assign the roles in our use case for +managing a security application integrated with Code42. + +## Before you begin + +To integrate Code42 with a SIEM tool, you must first install and configure the Code42 CLI following the instructions in +[Getting Started](gettingstarted.md) the Code42 command-line interface. + +## Commands and query parameters +You can get security events in either a JSON or CEF format for use by your SIEM tool. You can query the data as a +scheduled job or run ad-hoc queries. Learn more about [searching](../commands/securitydata.md) using the CLI. + +## Run a query as a scheduled job + +Use your favorite scheduling tool, such as cron or Windows Task Scheduler, to run a query on a regular basis. Specify +the profile to use by including `--profile`. For example: + +```bash +code42 security-data send-to "https://syslog.example.com:514" -p TCP --profile profile1 -i +``` + +Note that it is best practice to use a separate profile when executing a scheduled task. This way, it is harder to +accidentally mess up your stored checkpoints by running `--incremental` adhoc queries. + +This query will send to the syslog server only the new security event data since the previous request. + +## Run an ad-hoc query + +Examples of ad-hoc queries you can run are as follows. + +Print security data since March 5 for a user in raw JSON format: + +```bash +code42 security-data print -f RAW-JSON -b 2020-03-05 --c42-username 'sean.cassidy@example.com' +``` + +Print security events since March 5 where a file was synced to a cloud service: +```bash +code42 security-data print -t CloudStorage -b 2020-03-05 +``` + +Write to a text file security events in raw JSON format where a file was read by browser or other app for a user since +March 5: +```bash +code42 security-data write-to /Users/sangita.maskey/Downloads/c42cli_output.txt -f RAW-JSON -b 2020-03-05 -t ApplicationRead --c42-username 'sean.cassidy@example.com' +``` + +Example output for a single exposure event (in default JSON format): + +```json +{ + "eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_944009394534374185_342", + "eventType": "CREATED", + "eventTimestamp": "2020-03-05T14:45:49.662Z", + "insertionTimestamp": "2020-03-05T15:10:47.930Z", + "filePath": "C:/Users/sean.cassidy/Google Drive/", + "fileName": "1582938269_Longfellow_Cloud_Arch_Redesign.drawio", + "fileType": "FILE", + "fileCategory": "DOCUMENT", + "fileSize": 6025, + "fileOwner": "Administrators", + "md5Checksum": "9ab754c9133afbf2f70d5fe64cde1110", + "sha256Checksum": "8c6ba142065373ae5277ecf9f0f68ab8f9360f42a82eb1dec2e1816d93d6b1b7", + "createTimestamp": "2020-03-05T14:29:33.455Z", + "modifyTimestamp": "2020-02-29T01:04:31Z", + "deviceUserName": "sean.cassidy@example.com", + "osHostName": "LAPTOP-091", + "domainName": "192.168.65.129", + "publicIpAddress": "71.34.10.80", + "privateIpAddresses": [ + "fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", + "192.168.65.129", + "0:0:0:0:0:0:0:1", + "127.0.0.1" + ], + "deviceUid": "942704829036142720", + "userUid": "887050325252344565", + "source": "Endpoint", + "exposure": [ + "CloudStorage" + ], + "syncDestination": "GoogleBackupAndSync" +} +``` + +## CEF Mapping + +The following tables map the data from the Code42 CLI to common event format (CEF). + +### Attribute mapping + +The table below maps JSON fields, CEF fields, and [Forensic Search fields](https://support.code42.com/Administrator/Cloud/Administration_console_reference/Forensic_Search_reference_guide) +to one another. + +```eval_rst + ++----------------------------+---------------------------------+----------------------------------------+ +| JSON field | CEF field | Forensic Search field | ++============================+=================================+========================================+ +| actor | suser | Actor | ++----------------------------+---------------------------------+----------------------------------------+ +| cloudDriveId | aid | n/a | ++----------------------------+---------------------------------+----------------------------------------+ +| createTimestamp | fileCreateTime | File Created Date | ++----------------------------+---------------------------------+----------------------------------------+ +| deviceUid | deviceExternalId | n/a | ++----------------------------+---------------------------------+----------------------------------------+ +| deviceUserName | suser | Username (Code42) | ++----------------------------+---------------------------------+----------------------------------------+ +| domainName | dvchost | Fully Qualified Domain Name | ++----------------------------+---------------------------------+----------------------------------------+ +| eventId | externalID | n/a | ++----------------------------+---------------------------------+----------------------------------------+ +| eventTimestamp | end | Date Observed | ++----------------------------+---------------------------------+----------------------------------------+ +| exposure | reason | Exposure Type | ++----------------------------+---------------------------------+----------------------------------------+ +| fileCategory | fileType | File Category | ++----------------------------+---------------------------------+----------------------------------------+ +| fileName | fname | Filename | ++----------------------------+---------------------------------+----------------------------------------+ +| filePath | filePath | File Path | ++----------------------------+---------------------------------+----------------------------------------+ +| fileSize | fsize | File Size | ++----------------------------+---------------------------------+----------------------------------------+ +| insertionTimestamp | rt | n/a | ++----------------------------+---------------------------------+----------------------------------------+ +| md5Checksum | fileHash | MD5 Hash | ++----------------------------+---------------------------------+----------------------------------------+ +| modifyTimesamp | fileModificationTime | File Modified Date | ++----------------------------+---------------------------------+----------------------------------------+ +| osHostName | shost | Hostname | ++----------------------------+---------------------------------+----------------------------------------+ +| processName | sproc | Executable Name (Browser or Other App) | ++----------------------------+---------------------------------+----------------------------------------+ +| processOwner | spriv | Process User (Browser or Other App) | ++----------------------------+---------------------------------+----------------------------------------+ +| publiclpAddress | src | IP Address (public) | ++----------------------------+---------------------------------+----------------------------------------+ +| removableMediaBusType | cs1, | Device Bus Type (Removable Media) | +| | Code42AEDRemovableMediaBusType | | ++----------------------------+---------------------------------+----------------------------------------+ +| removableMediaCapacity | cn1, | Device Capacity (Removable Media) | +| | Code42AEDRemovableMediaCapacity | | ++----------------------------+---------------------------------+----------------------------------------+ +| removableMediaName | cs3, | Device Media Name (Removable Media) | +| | Code42AEDRemovableMediaName | | ++----------------------------+---------------------------------+----------------------------------------+ +| removableMediaSerialNumber | cs4 | Device Serial Number (Removable Media) | ++----------------------------+---------------------------------+----------------------------------------+ +| removableMediaVendor | cs2, | Device Vendor (Removable Media) | +| | Code42AEDRemovableMediaVendor | | ++----------------------------+---------------------------------+----------------------------------------+ +| sharedWith | duser | Shared With | ++----------------------------+---------------------------------+----------------------------------------+ +| syncDestination | destinationServiceName | Sync Destination (Cloud) | ++----------------------------+---------------------------------+----------------------------------------+ +| url | filePath | URL | ++----------------------------+---------------------------------+----------------------------------------+ +| userUid | suid | n/a | ++----------------------------+---------------------------------+----------------------------------------+ +| windowTitle | requestClientApplication | Tab/Window Title | ++----------------------------+---------------------------------+----------------------------------------+ +| tabUrl | request | Tab URL | ++----------------------------+---------------------------------+----------------------------------------+ +| emailSender | suser | Sender | ++----------------------------+---------------------------------+----------------------------------------+ +| emailRecipients | duser | Recipients | ++----------------------------+---------------------------------+----------------------------------------+ +``` + +### Event mapping + +See the table below to map exfiltration events to CEF signature IDs. + +```eval_rst + ++--------------------+-----------+ +| Exfiltration event | CEF field | ++====================+===========+ +| CREATED | C42200 | ++--------------------+-----------+ +| MODIFIED | C42201 | ++--------------------+-----------+ +| DELETED | C42202 | ++--------------------+-----------+ +| READ_BY_APP | C42203 | ++--------------------+-----------+ +| EMAILED | C42204 | ++--------------------+-----------+ +``` + diff --git a/setup.py b/setup.py index bf25f1036..ec2759a06 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,9 @@ "pytest==4.6.5", "pytest-cov == 2.8.1", "pytest-mock==2.0.0", + "recommonmark", + "sphinx", + "sphinx_rtd_theme", "tox==3.14.3", ] }, diff --git a/src/code42cli/args.py b/src/code42cli/args.py index 3a6b86554..826dee193 100644 --- a/src/code42cli/args.py +++ b/src/code42cli/args.py @@ -2,7 +2,7 @@ import inspect -PROFILE_HELP = u"The name of the Code42 profile to use when executing this command." +PROFILE_HELP = u"The name of the Code42 CLI profile to use when executing this command." SDK_ARG_NAME = u"sdk" PROFILE_ARG_NAME = u"profile" diff --git a/src/code42cli/cmds/alerts/main.py b/src/code42cli/cmds/alerts/main.py index 6c9cafb50..690677b0e 100644 --- a/src/code42cli/cmds/alerts/main.py +++ b/src/code42cli/cmds/alerts/main.py @@ -112,13 +112,13 @@ def _load_search_args(arg_collection): AlertFilterArguments.SEVERITY: ArgConfig( u"--{}".format(AlertFilterArguments.SEVERITY), nargs=u"+", - help=u"Filter alerts by severity. Defaults to returning all severities. Available choices={0}".format( + help=u"Filter alerts by severity. Defaults to returning all severities. Available choices={0}.".format( list(AlertSeverity()) ), ), AlertFilterArguments.STATE: ArgConfig( u"--{}".format(AlertFilterArguments.STATE), - help=u"Filter alerts by state. Defaults to returning all states. Available choices={0}".format( + help=u"Filter alerts by state. Defaults to returning all states. Available choices={0}.".format( list(AlertState()) ), ), @@ -173,7 +173,7 @@ def _load_search_args(arg_collection): AlertFilterArguments.RULE_TYPE: ArgConfig( u"--{}".format(AlertFilterArguments.RULE_TYPE.replace("_", "-")), metavar=u"RULE_TYPE", - help=u"Filter alerts by including the given rule type(s). Available choices={0}".format( + help=u"Filter alerts by including the given rule type(s). Available choices={0}.".format( list(RuleType()) ), nargs=u"+", @@ -181,7 +181,7 @@ def _load_search_args(arg_collection): AlertFilterArguments.EXCLUDE_RULE_TYPE: ArgConfig( u"--{}".format(AlertFilterArguments.EXCLUDE_RULE_TYPE.replace("_", "-")), metavar=u"RULE_TYPE", - help=u"Filter alerts by excluding the given rule type(s). Available choices={0}".format( + help=u"Filter alerts by excluding the given rule type(s). Available choices={0}.".format( list(RuleType()) ), nargs=u"+", diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py index 0bf3976e7..e4fd29e73 100644 --- a/src/code42cli/cmds/alerts/rules/commands.py +++ b/src/code42cli/cmds/alerts/rules/commands.py @@ -13,15 +13,17 @@ def _customize_add_arguments(argument_collection): rule_id = argument_collection.arg_configs[u"rule_id"] - rule_id.set_help(u"Observer ID of the rule to be updated. Required.") + rule_id.set_help(u"Observer ID of the rule to be updated.") username = argument_collection.arg_configs[u"username"] - username.set_help(u"The username of the user to add to the alert rule. Required.") + username.add_short_option_name("-u") + username.set_help(u"The username of the user to add to the alert rule.") def _customize_remove_arguments(argument_collection): rule_id = argument_collection.arg_configs[u"rule_id"] rule_id.set_help(u"Observer ID of the rule to be updated.") username = argument_collection.arg_configs[u"username"] + username.add_short_option_name("-u") username.set_help(u"The username of the user to remove from the alert rule.") @@ -30,11 +32,19 @@ def _customize_list_arguments(argument_collection): rule_id.set_help(u"Observer ID of the rule.") -def _customize_bulk_arguments(argument_collection): +def _customize_bulk_add_arguments(argument_collection): + _customize_bulk_arguments(argument_collection, u"adding") + + +def _customize_bulk_remove_arguments(argument_collection): + _customize_bulk_arguments(argument_collection, u"removing") + + +def _customize_bulk_arguments(argument_collection, action): file_name = argument_collection.arg_configs[u"file_name"] file_name.set_help( u"The path to the csv file with columns 'rule_id,username' " - u"for bulk adding users to the alert rule." + u"for bulk {} users to the alert rule.".format(action) ) @@ -58,7 +68,7 @@ def _generate_template_file(cmd, path=None): def _load_bulk_generate_template_description(argument_collection): cmd_type = argument_collection.arg_configs[u"cmd"] - cmd_type.set_help(u"The type of command the template with be used for.") + cmd_type.set_help(u"The type of command the template will be used for.") cmd_type.set_choices(BulkCommandType()) @@ -72,7 +82,7 @@ def load_commands(self): generate_template_cmd = Command( self.GENERATE_TEMPLATE, - u"Generate the necessary csv template needed for bulk adding users.", + u"Generate the necessary csv template for bulk actions.", u"{} generate-template ".format(usage_prefix), handler=_generate_template_file, arg_customizer=_load_bulk_generate_template_description, @@ -80,20 +90,18 @@ def load_commands(self): bulk_add = Command( self.ADD, - u"Update alert rule criteria to add users and all their aliases. " - u"CSV file format: rule_id,username", + u"Add users to alert rules. " u"CSV file format: `rule_id,username`.", u"{} add ".format(usage_prefix), handler=add_bulk_users, - arg_customizer=_customize_bulk_arguments, + arg_customizer=_customize_bulk_add_arguments, ) bulk_remove = Command( self.REMOVE, - u"Update alert rule criteria to remove users and all their aliases. " - u"CSV file format: rule_id,username", + u"Remove users from alert rules. " u"CSV file format: `rule_id,username`.", u"{} remove ".format(usage_prefix), handler=remove_bulk_users, - arg_customizer=_customize_bulk_arguments, + arg_customizer=_customize_bulk_remove_arguments, ) return [generate_template_cmd, bulk_add, bulk_remove] @@ -115,7 +123,7 @@ def load_commands(self): add = Command( self.ADD_USER, - u"Update alert rule criteria to monitor user aliases against the given username.", + u"Add a user to an alert rule.", u"{} add-user --rule-id --username ".format(usage_prefix), handler=add_user, arg_customizer=_customize_add_arguments, @@ -123,7 +131,7 @@ def load_commands(self): remove = Command( self.REMOVE_USER, - u"Update alert rule criteria to remove a user and all their aliases.", + u"Remove a user from an alert rule.", u"{} remove-user --rule-id --username ".format(usage_prefix), handler=remove_user, arg_customizer=_customize_remove_arguments, @@ -138,7 +146,7 @@ def load_commands(self): show = Command( self.SHOW, - u"Fetch configured alert-rules against the rule ID.", + u"Print out detailed alert rule criteria.", u"{} show ".format(usage_prefix), handler=show_rule, arg_customizer=_customize_list_arguments, diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index f8e360eab..df2c46342 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -95,7 +95,9 @@ def load_subcommands(self): return [bulk, add, remove] def _load_bulk_subcommands(self): - add = self.bulk_subcommand_loader.create_bulk_add_command(self.bulk_add_employees) + add = self.bulk_subcommand_loader.create_bulk_add_command( + self.bulk_add_employees, self.handlers.add_employee + ) remove = self.bulk_subcommand_loader.create_bulk_remove_command(self.bulk_remove_employees) commands = [add, remove] @@ -110,10 +112,10 @@ def _load_bulk_subcommands(self): def _get_risk_tags_bulk_subcommands(self): bulk_add_risk_tags = self.bulk_subcommand_loader.create_bulk_add_risk_tags_command( - self.bulk_add_risk_tags + self.bulk_add_risk_tags, add_risk_tags ) bulk_remove_risk_tags = self.bulk_subcommand_loader.create_bulk_remove_risk_tags_command( - self.bulk_remove_risk_tags + self.bulk_remove_risk_tags, remove_risk_tags ) self.handlers.add_handler(u"add_risk_tags", add_risk_tags) @@ -185,7 +187,7 @@ def bulk_remove_risk_tags(self, sdk, profile, csv_file): def load_username_description(argument_collection): """Loads the arg descriptions for the `username` CLI parameter.""" username = argument_collection.arg_configs[DetectionListUserKeys.USERNAME] - username.set_help(u"A code42 username for an employee.") + username.set_help(u"A Code42 username for an employee.") def load_user_descriptions(argument_collection): diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py index c744248d9..63f094571 100644 --- a/src/code42cli/cmds/detectionlists/commands.py +++ b/src/code42cli/cmds/detectionlists/commands.py @@ -1,3 +1,5 @@ +import inspect + from code42cli.bulk import BulkCommandType from code42cli.commands import Command, SubcommandLoader from code42cli.cmds.detectionlists.bulk import HighRiskBulkCommandType @@ -79,47 +81,55 @@ def create_bulk_generate_template_command(self, handler): def create_hre_bulk_generate_template_command(self, handler): return Command( u"generate-template", - u"Generate the necessary csv template needed for bulk adding users.", + u"Generate the necessary csv template for bulk actions.", u"{} generate-template ".format(self._bulk_usage_prefix), handler=handler, arg_customizer=self._load_hre_bulk_generate_template_description, ) - def create_bulk_add_command(self, handler): + def create_bulk_add_command(self, cmd_handler, row_handler): + file_format = _get_file_format(row_handler) return Command( self.ADD, - u"Bulk add users to the {} detection list using a csv file.".format(self._name), - u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.ADD), - handler=handler, + u"Add users to the {} detection list. CSV file format: `{}`.".format( + self._name, file_format + ), + u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.ADD), + handler=cmd_handler, arg_customizer=self._load_bulk_add_description, ) - def create_bulk_remove_command(self, handler): + def create_bulk_remove_command(self, cmd_handler): return Command( self.REMOVE, - u"Bulk remove users from the {} detection list using a file.".format(self._name), + u"Remove users from the {} detection list. " + u"The file format is an end-line-delimited list of users.".format(self._name), u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.REMOVE), - handler=handler, + handler=cmd_handler, arg_customizer=self._load_bulk_remove_description, ) - def create_bulk_add_risk_tags_command(self, handler): + def create_bulk_add_risk_tags_command(self, cmd_handler, row_handler): + file_format = _get_file_format(row_handler) return Command( u"add-risk-tags", - u"Associates risk tags with a user in bulk.", + u"Associates risk tags with a user in bulk. CSV file format: `{}`.".format(file_format), u"{} {} ".format(self._bulk_usage_prefix, HighRiskBulkCommandType.ADD_RISK_TAG), - handler=handler, + handler=cmd_handler, arg_customizer=self._load_bulk_add_risk_tags_description, ) - def create_bulk_remove_risk_tags_command(self, handler): + def create_bulk_remove_risk_tags_command(self, cmd_handler, row_handler): + file_format = _get_file_format(row_handler) return Command( u"remove-risk-tags", - u"Disassociates risk tags from a user in bulk.", + u"Disassociates risk tags from a user in bulk. CSV file format: `{}`.".format( + file_format + ), u"{} {} ".format( self._bulk_usage_prefix, HighRiskBulkCommandType.REMOVE_RISK_TAG ), - handler=handler, + handler=cmd_handler, arg_customizer=self._load_bulk_remove_risk_tags_description, ) @@ -146,7 +156,7 @@ def _load_bulk_add_description(self, argument_collection): def _load_bulk_remove_description(self, argument_collection): users_file = argument_collection.arg_configs[u"users_file"] users_file.set_help( - u"A file containing a line-separated list of users to remove form the {} detection list".format( + u"A file containing a line-separated list of users to remove form the {} detection list.".format( self._name ) ) @@ -166,3 +176,10 @@ def _load_bulk_remove_risk_tags_description(self, argument_collection): u"from the {} detection list. " u"e.g. test@email.com,tag1 tag2 tag3".format(self._name) ) + + +def _get_file_format(row_handler): + args = inspect.getargspec(row_handler).args + args.remove(u"profile") + args.remove(u"sdk") + return u", ".join(args) diff --git a/src/code42cli/cmds/detectionlists/departing_employee.py b/src/code42cli/cmds/detectionlists/departing_employee.py index 9a9756343..b4d4c3660 100644 --- a/src/code42cli/cmds/detectionlists/departing_employee.py +++ b/src/code42cli/cmds/detectionlists/departing_employee.py @@ -39,7 +39,7 @@ def add_departing_employee( profile (C42Profile): Your code42 profile. username (str): The username of the employee to add. cloud_alias (str): An alternative email address for another cloud service. - departure_date (str): The date the employee is departing in format `YYYY-MM-DD`. + departure_date (str): The date the employee is departing in format `yyyy-MM-dd`. notes: (str): Notes about the employee. """ user_id = get_user_id(sdk, username) @@ -61,4 +61,4 @@ def remove_departing_employee(sdk, profile, username): def _load_add_description(argument_collection): load_user_descriptions(argument_collection) departure_date = argument_collection.arg_configs[u"departure_date"] - departure_date.set_help(u"The date the employee is departing in format YYYY-MM-DD.") + departure_date.set_help(u"The date the employee is departing in format yyyy-MM-dd.") diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index 48898ec0f..c8c6d63c3 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -22,7 +22,7 @@ def load_commands(self): print_func = Command( self.PRINT, - u"Print file events to stdout", + u"Print file events to stdout.", u"{} {}".format(usage_prefix, u"print "), handler=print_out, arg_customizer=_load_search_args, diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index bc320ad67..2cb3528df 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -2,8 +2,8 @@ _FORMAT_VALUE_ERROR_MESSAGE = ( - u"input must be a date/time string (e.g. 'YYYY-MM-DD', " - u"'YY-MM-DD HH:MM', 'YY-MM-DD HH:MM:SS'), or a short value in days, " + u"input must be a date/time string (e.g. 'yyyy-MM-dd', " + u"'yy-MM-dd HH:MM', 'yy-MM-dd HH:MM:SS'), or a short value in days, " u"hours, or minutes (e.g. 30d, 24h, 15m)" ) diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py index 22724fa62..122e9692d 100644 --- a/src/code42cli/parser.py +++ b/src/code42cli/parser.py @@ -24,7 +24,16 @@ class ArgumentParserError(Exception): class CommandParser(argparse.ArgumentParser): def __init__(self, **kwargs): # noinspection PyTypeChecker - super(CommandParser, self).__init__(formatter_class=RawDescriptionHelpFormatter, **kwargs) + super(CommandParser, self).__init__( + formatter_class=RawDescriptionHelpFormatter, add_help=False, **kwargs + ) + self.add_argument( + "-h", + "--help", + action="help", + default=argparse.SUPPRESS, + help="Show this help message and exit.", + ) def prepare_command(self, command, path_parts): parser = self._get_parser(command, path_parts) From c089404538095443c6df40ad4ee2bc49855cb025 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 29 May 2020 15:40:03 -0500 Subject: [PATCH 070/349] Print instead of double error (#89) --- src/code42cli/invoker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py index 87fba4998..3559ad807 100644 --- a/src/code42cli/invoker.py +++ b/src/code42cli/invoker.py @@ -99,7 +99,7 @@ def _try_run_command(self, command, path_parts, input_args): logger.print_and_log_error(u"{}".format(err)) possible_correct_words = self._find_incorrect_word_match(err, path_parts) if possible_correct_words: - logger.print_and_log_error(u"Did you mean one of the following?") + logger.print_info(u"Did you mean one of the following?") for possible_correct_word in possible_correct_words: logger.print_info(u" {}".format(possible_correct_word)) From 37c7f275f4eedf0c068e23088ce2d47d74b21c10 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Tue, 2 Jun 2020 10:15:13 -0500 Subject: [PATCH 071/349] Legal hold implementation (#82) (#90) * Legal hold implementation (#82) Co-authored-by: Alan Grgic * print "No active matter members." instead of "Active matter members:\nNone" * add comment to clarify None/True state for `active` arg * changelog periods * add additionally * with => will * move matter_id accessible check to remove_user * print "No inactive matter members." fix tests * Remove extra space Co-authored-by: Tim Abramson Co-authored-by: tim.abramson Co-authored-by: Juliya Smith --- .github/workflows/build.yml | 2 +- CHANGELOG.md | 14 + setup.py | 6 +- src/code42cli/bulk.py | 2 +- src/code42cli/cmds/alerts/rules/commands.py | 17 +- src/code42cli/cmds/alerts/rules/user_rule.py | 20 +- src/code42cli/cmds/detectionlists/__init__.py | 20 +- .../cmds/detectionlists/departing_employee.py | 4 +- .../cmds/detectionlists/high_risk_employee.py | 4 +- src/code42cli/cmds/legal_hold/__init__.py | 150 ++++++++++ src/code42cli/cmds/legal_hold/commands.py | 168 +++++++++++ src/code42cli/errors.py | 19 +- src/code42cli/main.py | 10 + src/code42cli/util.py | 46 ++- tests/cmds/alerts/test_cursor_store.py | 4 +- tests/cmds/detectionlists/conftest.py | 15 - .../detectionlists/test_departing_employee.py | 4 +- .../detectionlists/test_high_risk_employee.py | 2 +- tests/cmds/detectionlists/test_init.py | 13 +- tests/cmds/legal_hold/__init__.py | 0 tests/cmds/legal_hold/test_legal_hold.py | 271 ++++++++++++++++++ tests/cmds/securitydata/test_cursor_store.py | 4 +- tests/conftest.py | 16 ++ tests/test_commands.py | 6 +- tests/test_main.py | 5 + tests/test_util.py | 8 +- tox.ini | 11 +- 27 files changed, 748 insertions(+), 93 deletions(-) create mode 100644 src/code42cli/cmds/legal_hold/__init__.py create mode 100644 src/code42cli/cmds/legal_hold/commands.py create mode 100644 tests/cmds/legal_hold/__init__.py create mode 100644 tests/cmds/legal_hold/test_legal_hold.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1057266a0..67af131aa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [2.7, 3.5, 3.6, 3.7, 3.8] + python: [3.5, 3.6, 3.7, 3.8] steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 254f0059e..3ec3bae0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,20 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `path` - `remove`: that takes a csv file with rule IDs and usernames. +- `code42 legal-hold` commands: + - `add-user` with parameters `--matter-id/-m` and `--username/-u`. + - `remove-user` with parameters `--matter-id/-m` and `--username/-u`. + - `list` prints out existing active legal hold matters. + - `show` takes a `matter_id` and prints details of the matter. + - optional argument `--include-inactive` additionally prints matter memberships that are no longer active. + - optional argument `--include-policy` additionally prints out the matter's backup preservation policy in json form. + - `bulk` with subcommands: + - `add-user`: that takes a csv file with matter IDs and usernames. + - `remove-user`: that takes a csv file with matter IDs and usernames. + - `generate-template`: that creates the file templates. + - `cmd`: with options `add` and `remove`. + - `path` + - Success messages for `profile delete` and `profile update`. - Additional information in the error log file: diff --git a/setup.py b/setup.py index ec2759a06..819d0ceaa 100644 --- a/setup.py +++ b/setup.py @@ -19,9 +19,9 @@ long_description_content_type="text/markdown", packages=find_packages("src"), package_dir={"": "src"}, - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", + python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ - "c42eventextractor==0.3.0b1", + "c42eventextractor==0.3.1", "keyring==18.0.1", "keyrings.alt==3.2.0", "py42>=1.2.0", @@ -46,8 +46,6 @@ "Natural Language :: English", "License :: OSI Approved :: MIT License", "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 4c8c68de2..a94c91394 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -23,7 +23,7 @@ def generate_template(handler, path=None): `handler` only has one parameter that is not `sdk` or `profile`, it will create a blank file. This is useful for commands such as `remove` which only require a list of users. """ - path = path or u"{0}/{1}.csv".format(os.getcwd(), str(handler.__name__)) + path = path or os.path.join(os.getcwd(), u"{}.csv".format(str(handler.__name__))) args = [ arg for arg in inspect.getargspec(handler).args diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py index e4fd29e73..0a2fae2dc 100644 --- a/src/code42cli/cmds/alerts/rules/commands.py +++ b/src/code42cli/cmds/alerts/rules/commands.py @@ -1,3 +1,6 @@ +import os + +from code42cli.commands import Command from code42cli import MAIN_COMMAND from code42cli.commands import Command, SubcommandLoader from code42cli.bulk import generate_template, BulkCommandType @@ -52,17 +55,21 @@ def _generate_template_file(cmd, path=None): """Generates a template file a user would need to fill-in for bulk operating. Args: - cmd (str or unicode): An option from the `BulkCommandType` enum specifying which type of file to - generate. - path (str or unicode, optional): A path to put the file after it's generated. If None, will use - the current working directory. Defaults to None. + cmd (str or unicode): An option from the `BulkCommandType` enum specifying which type of + file to generate. + path (str or unicode, optional): A path to put the file after it's generated. If None, will + use the current working directory. Defaults to None. """ handler = None + filename = u"alert_rule.csv" if cmd == BulkCommandType.ADD: handler = add_user + filename = u"add_users_to_{}".format(filename) elif cmd == BulkCommandType.REMOVE: handler = remove_user - + filename = u"remove_users_from_{}".format(filename) + if not path: + path = os.path.join(os.getcwd(), filename) generate_template(handler, path) diff --git a/src/code42cli/cmds/alerts/rules/user_rule.py b/src/code42cli/cmds/alerts/rules/user_rule.py index 49ecf5882..a84d68bc9 100644 --- a/src/code42cli/cmds/alerts/rules/user_rule.py +++ b/src/code42cli/cmds/alerts/rules/user_rule.py @@ -1,24 +1,24 @@ +from collections import OrderedDict + from py42.exceptions import Py42InternalServerError from py42.util import format_json from code42cli.errors import InvalidRuleTypeError -from code42cli.util import format_to_table, find_format_width +from code42cli.util import format_to_table, find_format_width, get_user_id from code42cli.bulk import run_bulk_process from code42cli.file_readers import create_csv_reader from code42cli.logger import get_main_cli_logger -from code42cli.cmds.detectionlists import get_user_id from code42cli.cmds.alerts.rules.enums import AlertRuleTypes -_HEADER_KEYS_MAP = { - u"observerRuleId": u"RuleId", - u"name": u"Name", - u"severity": u"Severity", - u"type": u"Type", - u"ruleSource": u"Source", - u"isEnabled": u"Enabled", -} +_HEADER_KEYS_MAP = OrderedDict() +_HEADER_KEYS_MAP[u"observerRuleId"] = u"RuleId" +_HEADER_KEYS_MAP[u"name"] = u"Name" +_HEADER_KEYS_MAP[u"severity"] = u"Severity" +_HEADER_KEYS_MAP[u"type"] = u"Type" +_HEADER_KEYS_MAP[u"ruleSource"] = u"Source" +_HEADER_KEYS_MAP[u"isEnabled"] = u"Enabled" def add_user(sdk, profile, rule_id, username): diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index df2c46342..854eb5ef7 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -3,9 +3,10 @@ from code42cli.cmds.detectionlists.commands import DetectionListSubcommandLoader from code42cli.bulk import generate_template, run_bulk_process from code42cli.file_readers import create_csv_reader, create_flat_file_reader -from code42cli.errors import UserAlreadyAddedError, UserDoesNotExistError, UnknownRiskTagError +from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags from code42cli.cmds.detectionlists.bulk import BulkDetectionList, BulkHighRiskEmployee +from code42cli.util import get_user_id def try_handle_user_already_added_error(bad_request_err, username_tried_adding, list_name): @@ -205,23 +206,6 @@ def load_user_descriptions(argument_collection): notes.set_help(u"Notes about the employee.") -def get_user_id(sdk, username): - """Returns the user's UID (referred to by `user_id` in detection lists). If the user does not - exist, it prints an error and exits. - - Args: - sdk (py42.sdk.SDKClient): The py42 sdk. - username (str or unicode): The username of the user to get an ID for. - - Returns: - str: The user ID for the user with the given username. - """ - users = sdk.users.get_by_username(username)[u"users"] - if not users: - raise UserDoesNotExistError(username) - return users[0][u"userUid"] - - def update_user(sdk, user_id, cloud_alias=None, risk_tag=None, notes=None): """Updates a detection list user. diff --git a/src/code42cli/cmds/detectionlists/departing_employee.py b/src/code42cli/cmds/detectionlists/departing_employee.py index b4d4c3660..a552cb2b5 100644 --- a/src/code42cli/cmds/detectionlists/departing_employee.py +++ b/src/code42cli/cmds/detectionlists/departing_employee.py @@ -2,11 +2,11 @@ DetectionList, DetectionListHandlers, load_user_descriptions, - get_user_id, update_user, try_handle_user_already_added_error, DetectionListSubcommandLoader, ) +from code42cli.util import get_user_id from code42cli.cmds.detectionlists.enums import DetectionLists from py42.exceptions import Py42BadRequestError @@ -48,7 +48,7 @@ def add_departing_employee( sdk.detectionlists.departing_employee.add(user_id, departure_date) update_user(sdk, user_id, cloud_alias, notes=notes) except Py42BadRequestError as err: - list_name = DetectionLists.DEPARTING_EMPLOYEE + list_name = u"{} list".format(DetectionLists.DEPARTING_EMPLOYEE) try_handle_user_already_added_error(err, username, list_name) raise diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py index 4f18c369f..d9da4bd40 100644 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ b/src/code42cli/cmds/detectionlists/high_risk_employee.py @@ -6,7 +6,6 @@ DetectionList, DetectionListHandlers, load_user_descriptions, - get_user_id, update_user, try_handle_user_already_added_error, add_risk_tags, @@ -14,6 +13,7 @@ load_username_description, handle_list_args, ) +from code42cli.util import get_user_id from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags @@ -71,7 +71,7 @@ def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=No sdk.detectionlists.high_risk_employee.add(user_id) update_user(sdk, user_id, cloud_alias, risk_tag, notes) except Py42BadRequestError as err: - list_name = DetectionLists.HIGH_RISK_EMPLOYEE + list_name = u"{} list".format(DetectionLists.HIGH_RISK_EMPLOYEE) try_handle_user_already_added_error(err, username, list_name) raise diff --git a/src/code42cli/cmds/legal_hold/__init__.py b/src/code42cli/cmds/legal_hold/__init__.py new file mode 100644 index 000000000..28f509cdc --- /dev/null +++ b/src/code42cli/cmds/legal_hold/__init__.py @@ -0,0 +1,150 @@ +from collections import OrderedDict +from functools import lru_cache +from pprint import pprint + +from py42.exceptions import Py42ForbiddenError, Py42BadRequestError + + +from code42cli.errors import ( + UserAlreadyAddedError, + UserNotInLegalHoldError, + LegalHoldNotFoundOrPermissionDeniedError, +) +from code42cli.util import ( + format_to_table, + find_format_width, + format_string_list_to_columns, + get_user_id, +) +from code42cli.bulk import run_bulk_process +from code42cli.file_readers import create_csv_reader +from code42cli.logger import get_main_cli_logger + +_MATTER_KEYS_MAP = OrderedDict() +_MATTER_KEYS_MAP[u"legalHoldUid"] = u"Matter ID" +_MATTER_KEYS_MAP[u"name"] = u"Name" +_MATTER_KEYS_MAP[u"description"] = u"Description" +_MATTER_KEYS_MAP[u"creator_username"] = u"Creator" +_MATTER_KEYS_MAP[u"creationDate"] = u"Creation Date" + +logger = get_main_cli_logger() + + +def add_user(sdk, matter_id, username): + user_id = get_user_id(sdk, username) + matter = _check_matter_is_accessible(sdk, matter_id) + try: + sdk.legalhold.add_to_matter(user_id, matter_id) + except Py42BadRequestError as e: + if u"USER_ALREADY_IN_HOLD" in e.response.text: + matter_id_and_name_text = u"legal hold matter id={}, name={}".format( + matter_id, matter[u"name"] + ) + raise UserAlreadyAddedError(username, matter_id_and_name_text) + raise + + +def remove_user(sdk, matter_id, username): + _check_matter_is_accessible(sdk, matter_id) + membership_id = _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id) + sdk.legalhold.remove_from_matter(membership_id) + + +def get_matters(sdk): + matters = _get_all_active_matters(sdk) + if matters: + rows, column_size = find_format_width(matters, _MATTER_KEYS_MAP) + format_to_table(rows, column_size) + + +def add_bulk_users(sdk, file_name): + reader = create_csv_reader(file_name) + run_bulk_process( + lambda matter_id, username: add_user(sdk, matter_id, username), reader, + ) + + +def remove_bulk_users(sdk, file_name): + reader = create_csv_reader(file_name) + run_bulk_process( + lambda matter_id, username: remove_user(sdk, matter_id, username), reader, + ) + + +def show_matter(sdk, matter_id, include_inactive=False, include_policy=False): + matter = _check_matter_is_accessible(sdk, matter_id) + matter[u"creator_username"] = matter[u"creator"][u"username"] + + # if `active` is None then all matters (whether active or inactive) are returned. True returns + # only those that are active. + active = None if include_inactive else True + memberships = _get_legal_hold_memberships_for_matter(sdk, matter_id, active=active) + active_usernames = [member[u"user"][u"username"] for member in memberships if member[u"active"]] + inactive_usernames = [ + member[u"user"][u"username"] for member in memberships if not member[u"active"] + ] + + rows, column_size = find_format_width([matter], _MATTER_KEYS_MAP) + + print(u"") + format_to_table(rows, column_size) + if active_usernames: + print(u"\nActive matter members:\n") + format_string_list_to_columns(active_usernames) + else: + print("\nNo active matter members.\n") + + if include_inactive: + if inactive_usernames: + print(u"\nInactive matter members:\n") + format_string_list_to_columns(inactive_usernames) + else: + print("No inactive matter members.\n") + + if include_policy: + _get_and_print_preservation_policy(sdk, matter[u"holdPolicyUid"]) + print(u"") + + +def _get_and_print_preservation_policy(sdk, policy_uid): + preservation_policy = sdk.legalhold.get_policy_by_uid(policy_uid) + print(u"\nPreservation Policy:\n") + pprint(preservation_policy._data_root) + + +def _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id): + user_id = get_user_id(sdk, username) + memberships = _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True) + for member in memberships: + if member[u"user"][u"userUid"] == user_id: + return member[u"legalHoldMembershipUid"] + raise UserNotInLegalHoldError(username, matter_id) + + +def _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True): + memberships_generator = sdk.legalhold.get_all_matter_custodians( + legal_hold_uid=matter_id, active=active + ) + memberships = [ + member for page in memberships_generator for member in page[u"legalHoldMemberships"] + ] + return memberships + + +def _get_all_active_matters(sdk): + matters_generator = sdk.legalhold.get_all_matters() + matters = [ + matter for page in matters_generator for matter in page[u"legalHolds"] if matter[u"active"] + ] + for matter in matters: + matter[u"creator_username"] = matter[u"creator"][u"username"] + return matters + + +@lru_cache(maxsize=None) +def _check_matter_is_accessible(sdk, matter_id): + try: + matter = sdk.legalhold.get_matter_by_uid(matter_id) + return matter + except (Py42BadRequestError, Py42ForbiddenError): + raise LegalHoldNotFoundOrPermissionDeniedError(matter_id) diff --git a/src/code42cli/cmds/legal_hold/commands.py b/src/code42cli/cmds/legal_hold/commands.py new file mode 100644 index 000000000..a98363213 --- /dev/null +++ b/src/code42cli/cmds/legal_hold/commands.py @@ -0,0 +1,168 @@ +import os + +from code42cli.args import ArgConfig +from code42cli.commands import Command, SubcommandLoader +from code42cli.bulk import generate_template, BulkCommandType +from code42cli.cmds.legal_hold import ( + add_user, + remove_user, + get_matters, + add_bulk_users, + remove_bulk_users, + show_matter, +) + + +class LegalHoldSubcommandLoader(SubcommandLoader): + ADD_USER = "add-user" + REMOVE_USER = "remove-user" + LIST = "list" + SHOW = "show" + BULK = "bulk" + + def __init__(self, root_command_name): + super(LegalHoldSubcommandLoader, self).__init__(root_command_name) + self._bulk_subcommand_loader = LegalHoldBulkSubcommandLoader(self.BULK) + + def load_commands(self): + """Sets up the `legal-hold` subcommand with all of its subcommands.""" + usage_prefix = u"code42 legal-hold" + + add = Command( + self.ADD_USER, + u"Add a user to a legal hold matter.", + u"{} add-user --matter-id --username ".format(usage_prefix), + handler=add_user, + arg_customizer=_customize_add_arguments, + ) + + remove = Command( + self.REMOVE_USER, + u"Remove a user from a legal hold matter.", + u"{} remove-user --matter-id --username ".format(usage_prefix), + handler=remove_user, + arg_customizer=_customize_remove_arguments, + ) + + list_matters = Command( + self.LIST, + u"Fetch existing legal hold matters.", + u"{} list".format(usage_prefix), + handler=get_matters, + ) + + show = Command( + self.SHOW, + u"Fetch all legal hold custodians for a given matter.", + u"{} show ".format(usage_prefix), + handler=show_matter, + arg_customizer=_customize_show_arguments, + ) + + bulk = Command( + self.BULK, + u"Tools for executing bulk commands.", + subcommand_loader=self._bulk_subcommand_loader, + ) + + return [add, remove, list_matters, show, bulk] + + +class LegalHoldBulkSubcommandLoader(SubcommandLoader): + GENERATE_TEMPLATE = u"generate-template" + ADD = u"add" + REMOVE = u"remove" + + def load_commands(self): + """Sets up the `legal-hold bulk` subcommands.""" + usage_prefix = u"code42 legal-hold bulk" + + bulk_add = Command( + u"add-user", + u"Bulk add users to legal hold matters from a csv file. CSV file format: matter_id,username", + u"{} add-user ".format(usage_prefix), + handler=add_bulk_users, + arg_customizer=_customize_bulk_arguments, + ) + + bulk_remove = Command( + u"remove-user", + u"Bulk remove users from legal hold matters from a csv file. CSV file format: matter_id,username", + u"{} remove-user ".format(usage_prefix), + handler=remove_bulk_users, + arg_customizer=_customize_bulk_arguments, + ) + + generate_template_cmd = Command( + u"generate-template", + u"Generate the necessary csv template needed for bulk adding users.", + u"{} generate-template ".format(usage_prefix), + handler=_generate_template_file, + arg_customizer=_load_bulk_generate_template_description, + ) + + return [bulk_add, bulk_remove, generate_template_cmd] + + +def _customize_add_arguments(argument_collection): + matter = argument_collection.arg_configs["matter_id"] + matter.add_short_option_name("-m") + matter.set_help("ID of the legal hold matter user will be added to. Required.") + username = argument_collection.arg_configs["username"] + username.add_short_option_name("-u") + username.set_help("The username of the user to add to the matter. Required.") + + +def _customize_remove_arguments(argument_collection): + matter = argument_collection.arg_configs["matter_id"] + matter.add_short_option_name("-m") + matter.set_help("ID of the legal hold matter user will be removed from. Required.") + username = argument_collection.arg_configs["username"] + username.add_short_option_name("-u") + username.set_help("The username of the user to remove from the matter. Required.") + + +def _customize_show_arguments(argument_collection): + matter_id = argument_collection.arg_configs[u"matter_id"] + matter_id.set_help(u"ID of the legal hold matter.") + args = { + u"include_inactive": ArgConfig( + u"--include-inactive", + action=u"store_true", + help=u"Include list of users who are no longer actively on this matter.", + ), + u"include_policy": ArgConfig( + u"--include-policy", + action=u"store_true", + help=u"Include the preservation policy (in json format) for this matter.", + ), + } + argument_collection.extend(args) + + +def _customize_bulk_arguments(argument_collection): + file_name = argument_collection.arg_configs[u"file_name"] + file_name.set_help( + u"The path to the csv file with columns 'matter_id,username' " + u"for bulk adding users to legal hold." + ) + + +def _generate_template_file(cmd, path=None): + handler = None + filename = u"legal_hold.csv" + if cmd == BulkCommandType.ADD: + handler = add_user + filename = u"add_users_to_{}".format(filename) + elif cmd == BulkCommandType.REMOVE: + handler = remove_user + filename = u"remove_users_from_{}".format(filename) + if not path: + path = os.path.join(os.getcwd(), filename) + generate_template(handler, path) + + +def _load_bulk_generate_template_description(argument_collection): + cmd_type = argument_collection.arg_configs[u"cmd"] + cmd_type.set_help(u"The type of command the template will be used for.") + cmd_type.set_choices(BulkCommandType()) diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index 2cb3528df..189d9a2c6 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -25,7 +25,7 @@ class Code42CLIError(Exception): class UserAlreadyAddedError(Code42CLIError): def __init__(self, username, list_name): - msg = u"'{}' is already on the {} list.".format(username, list_name) + msg = u"'{}' is already on the {}.".format(username, list_name) super(UserAlreadyAddedError, self).__init__(msg) @@ -53,6 +53,23 @@ def __init__(self, username): super(UserDoesNotExistError, self).__init__(u"User '{}' does not exist.".format(username)) +class UserNotInLegalHoldError(Code42CLIError): + def __init__(self, username, matter_id): + super(UserNotInLegalHoldError, self).__init__( + u"User '{}' is not an active member of legal hold matter '{}'".format( + username, matter_id + ) + ) + + +class LegalHoldNotFoundOrPermissionDeniedError(Code42CLIError): + def __init__(self, matter_id): + super(LegalHoldNotFoundOrPermissionDeniedError, self).__init__( + u"Matter with id={} either does not exist or your profile does not have permission to " + u"view it.".format(matter_id) + ) + + class DateArgumentError(Code42CLIError): def __init__(self, message=_FORMAT_VALUE_ERROR_MESSAGE): super(DateArgumentError, self).__init__(message) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 1319c2791..0b8c08b07 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -11,6 +11,7 @@ from code42cli.cmds.securitydata import main as secmain from code42cli.cmds.alerts import main as alertmain from code42cli.cmds.alerts.rules import commands as alertrules +from code42cli.cmds.legal_hold import commands as legalhold from code42cli.cmds.profile import ProfileSubcommandLoader from code42cli.commands import Command, SubcommandLoader from code42cli.invoker import CommandInvoker @@ -48,6 +49,7 @@ class MainSubcommandLoader(SubcommandLoader): ALERT_RULES = u"alert-rules" DEPARTING_EMPLOYEE = DetectionLists.DEPARTING_EMPLOYEE HIGH_RISK_EMPLOYEE = DetectionLists.HIGH_RISK_EMPLOYEE + LEGAL_HOLD = u"legal-hold" def load_commands(self): detection_lists_description = ( @@ -84,6 +86,11 @@ def load_commands(self): detection_lists_description.format(u"high risk employee"), subcommand_loader=self._create_high_risk_employee_loader(), ), + Command( + self.LEGAL_HOLD, + u"For adding and removing employees to legal hold matters.", + subcommand_loader=self._create_legal_hold_loader(), + ), ] def _create_profile_loader(self): @@ -104,6 +111,9 @@ def _create_departing_employee_loader(self): def _create_high_risk_employee_loader(self): return hre.HighRiskEmployeeSubcommandLoader(self.HIGH_RISK_EMPLOYEE) + def _create_legal_hold_loader(self): + return legalhold.LegalHoldSubcommandLoader(self.LEGAL_HOLD) + def main(): top = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 1b5f4184b..46a9e7fac 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,10 +1,14 @@ from __future__ import print_function import sys +import shutil + +from collections import OrderedDict from functools import wraps from os import makedirs, path from signal import signal, getsignal, SIGINT from code42cli.compat import open, str +from code42cli.errors import UserDoesNotExistError _PADDING_SIZE = 3 @@ -72,7 +76,7 @@ def find_format_width(record, header): # Set default max width items to column names max_width_item = dict(header.items()) for record_row in record: - row = {} + row = OrderedDict() for header_key in header.keys(): row[header_key] = record_row[header_key] max_width_item[header_key] = max( @@ -84,14 +88,29 @@ def find_format_width(record, header): def format_to_table(rows, column_size): - """Prints result in left justified format in a tabular form. - """ + """Prints result in left justified format in a tabular form.""" for row in rows: for key in row.keys(): print(str(row[key]).ljust(column_size[key] + _PADDING_SIZE), end=u" ") print(u"") +def format_string_list_to_columns(string_list, max_width=None): + """Prints a list of strings in justified columns and fits them neatly into specified width.""" + if not string_list: + return + if not max_width: + max_width, _ = shutil.get_terminal_size() + column_width = len(max(string_list, key=len)) + _PADDING_SIZE + num_columns = int(max_width / column_width) + format_string = u"{{:<{0}}}".format(column_width) * num_columns + batches = [string_list[i : i + num_columns] for i in range(0, len(string_list), num_columns)] + padding = [u"" for _ in range(num_columns)] + for batch in batches: + print(format_string.format(*batch + padding)) + print() + + def color_text_red(text): return u"\033[91m{}\033[0m".format(text) @@ -134,8 +153,25 @@ def _handle_interrupts(self, sig, frame): def __call__(self, func): @wraps(func) - def inner(*args, **kwds): + def inner(*args, **kwargs): with self: - return func(*args, **kwds) + return func(*args, **kwargs) return inner + + +def get_user_id(sdk, username): + """Returns the user's UID (referred to by `user_id` in detection lists). Raises + `UserDoesNotExistError` if the user doesn't exist in the Code42 server. + + Args: + sdk (py42.sdk.SDKClient): The py42 sdk. + username (str or unicode): The username of the user to get an ID for. + + Returns: + str: The user ID for the user with the given username. + """ + users = sdk.users.get_by_username(username)[u"users"] + if not users: + raise UserDoesNotExistError(username) + return users[0][u"userUid"] diff --git a/tests/cmds/alerts/test_cursor_store.py b/tests/cmds/alerts/test_cursor_store.py index f64882d0e..277a80084 100644 --- a/tests/cmds/alerts/test_cursor_store.py +++ b/tests/cmds/alerts/test_cursor_store.py @@ -9,9 +9,9 @@ def test_init_cursor_store_when_not_given_db_file_path_uses_expected_default_che self, sqlite_connection ): home_dir = path.expanduser("~") - expected_path = path.join(home_dir, ".code42cli/db") + expected_path = path.join(home_dir, ".code42cli", "db") db_table_name = "TEST" - expected_db_file_path = "{0}/file_event_checkpoints.db".format(expected_path) + expected_db_file_path = path.join(expected_path, "file_event_checkpoints.db") BaseCursorStore(db_table_name) sqlite_connection.assert_called_once_with(expected_db_file_path) diff --git a/tests/cmds/detectionlists/conftest.py b/tests/cmds/detectionlists/conftest.py index 177aa91f1..f1afa72ac 100644 --- a/tests/cmds/detectionlists/conftest.py +++ b/tests/cmds/detectionlists/conftest.py @@ -4,21 +4,6 @@ from py42.exceptions import Py42BadRequestError -TEST_ID = "TEST_ID" - - -@pytest.fixture -def sdk_with_user(sdk): - sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_ID}]} - return sdk - - -@pytest.fixture -def sdk_without_user(sdk): - sdk.users.get_by_username.return_value = {"users": []} - return sdk - - @pytest.fixture def bad_request_for_user_already_added(mocker): resp = mocker.MagicMock(spec=Response) diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py index 7406cb565..7ade01944 100644 --- a/tests/cmds/detectionlists/test_departing_employee.py +++ b/tests/cmds/detectionlists/test_departing_employee.py @@ -7,7 +7,7 @@ DepartingEmployeeSubcommandLoader, ) -from .conftest import TEST_ID +from ...conftest import TEST_ID from py42.exceptions import Py42BadRequestError @@ -23,7 +23,7 @@ def test_load_subcommands_loads_expected_commands(self): assert "add" in names assert "bulk" in names assert "remove" in names - + def test_loader_has_expected_detection_list_name(self): loader = DepartingEmployeeSubcommandLoader("test") assert "departing-employee" == loader.detection_list.name diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py index 669d74636..1546531ab 100644 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ b/tests/cmds/detectionlists/test_high_risk_employee.py @@ -8,7 +8,7 @@ ) from code42cli.cmds.detectionlists.enums import RiskTags -from .conftest import TEST_ID +from ...conftest import TEST_ID from py42.exceptions import Py42BadRequestError diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 0085cf7aa..84e379903 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -5,7 +5,6 @@ try_handle_user_already_added_error, DetectionList, DetectionListHandlers, - get_user_id, update_user, try_add_risk_tags, try_remove_risk_tags, @@ -16,8 +15,7 @@ from code42cli.bulk import BulkCommandType from code42cli.cmds.detectionlists.enums import RiskTags from code42cli.cmds.detectionlists.bulk import HighRiskBulkCommandType -from .conftest import TEST_ID -from ...conftest import create_mock_reader +from ...conftest import create_mock_reader, TEST_ID _NAMESPACE = "{}.cmds.detectionlists".format(PRODUCT_NAME) @@ -35,23 +33,18 @@ def bulk_processor(mocker): def test_try_handle_user_already_added_error_when_error_indicates_user_added_raises_UserAlreadyAddedError( - bad_request_for_user_already_added + bad_request_for_user_already_added, ): with pytest.raises(UserAlreadyAddedError): try_handle_user_already_added_error(bad_request_for_user_already_added, "name", "listname") def test_try_handle_user_already_added_error_when_error_does_not_indicate_user_added_returns_false( - generic_bad_request + generic_bad_request, ): assert not try_handle_user_already_added_error(generic_bad_request, "name", "listname") -def test_get_user_id_when_user_does_not_raise_error(sdk_without_user): - with pytest.raises(UserDoesNotExistError): - get_user_id(sdk_without_user, "risky employee") - - def test_update_user_adds_cloud_alias(sdk_with_user, profile): update_user(sdk_with_user, TEST_ID, cloud_alias="1@example.com") sdk_with_user.detectionlists.add_user_cloud_alias.assert_called_once_with( diff --git a/tests/cmds/legal_hold/__init__.py b/tests/cmds/legal_hold/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cmds/legal_hold/test_legal_hold.py b/tests/cmds/legal_hold/test_legal_hold.py new file mode 100644 index 000000000..ef6e2ad4a --- /dev/null +++ b/tests/cmds/legal_hold/test_legal_hold.py @@ -0,0 +1,271 @@ +import pytest + +from requests import Response, HTTPError + +from code42cli import PRODUCT_NAME +from code42cli.cmds.legal_hold import ( + add_user, + remove_user, + show_matter, + add_bulk_users, + remove_bulk_users, + _check_matter_is_accessible, +) +from code42cli.errors import ( + UserAlreadyAddedError, + UserNotInLegalHoldError, + LegalHoldNotFoundOrPermissionDeniedError, + UserDoesNotExistError, +) + +_NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) + +from py42.exceptions import Py42BadRequestError +from py42.response import Py42Response +from requests import Response + +from ...conftest import create_mock_reader + +TEST_MATTER_ID = "99999" +TEST_LEGAL_HOLD_MEMBERSHIP_UID = "88888" +TEST_LEGAL_HOLD_MEMBERSHIP_UID_2 = "77777" +ACTIVE_TEST_USERNAME = "user@example.com" +ACTIVE_TEST_USER_ID = "12345" +INACTIVE_TEST_USERNAME = "inactive@example.com" +INACTIVE_TEST_USER_ID = "54321" + +TEST_POLICY_UID = "66666" + +TEST_MATTER_RESULT = { + "legalHoldUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID, + "name": "Test_Matter", + "description": "", + "active": True, + "creationDate": "2020-01-01T00:00:00.000-06:00", + "creator": {"userUid": "942564422882759874", "username": "legal_admin@example.com"}, + "holdPolicyUid": TEST_POLICY_UID, +} + +ACTIVE_LEGAL_HOLD_MEMBERSHIP = { + "legalHoldMembershipUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID, + "user": {"userUid": ACTIVE_TEST_USER_ID, "username": ACTIVE_TEST_USERNAME}, + "active": True, +} +INACTIVE_LEGAL_HOLD_MEMBERSHIP = { + "legalHoldMembershipUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID_2, + "user": {"userUid": INACTIVE_TEST_USER_ID, "username": INACTIVE_TEST_USERNAME}, + "active": False, +} + + +EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": []}] +ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP]}] +ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ + {"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP, INACTIVE_LEGAL_HOLD_MEMBERSHIP]} +] +INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ + {"legalHoldMemberships": [INACTIVE_LEGAL_HOLD_MEMBERSHIP]} +] + +TEST_PRESERVATION_POLICY_UID = "1010101010" +TEST_PRESERVATION_POLICY_JSON = '{{"creationDate": "2020-01-01","legalHoldPolicyUid": {}}}'.format( + TEST_PRESERVATION_POLICY_UID +) + + +@pytest.fixture +def preservation_policy_response(mocker): + response = mocker.MagicMock(spec=Response) + response.text = TEST_PRESERVATION_POLICY_JSON + return Py42Response(response) + + +@pytest.fixture +def get_user_id_success(sdk): + sdk.users.get_by_username.return_value = {"users": [{"userUid": ACTIVE_TEST_USER_ID}]} + + +@pytest.fixture +def get_user_id_failure(sdk): + sdk.users.get_by_username.return_value = {"users": []} + + +@pytest.fixture +def check_matter_accessible_success(sdk): + sdk.legalhold.get_matter_by_uid.return_value = TEST_MATTER_RESULT + + +@pytest.fixture +def check_matter_accessible_failure(sdk): + sdk.legalhold.get_matter_by_uid.side_effect = Py42BadRequestError(HTTPError()) + + +@pytest.fixture +def user_already_added_response(mocker): + mock_response = mocker.MagicMock(spec=Response) + mock_response.text = "USER_ALREADY_IN_HOLD" + http_error = HTTPError() + http_error.response = mock_response + return Py42BadRequestError(http_error) + + +def test_add_user_raises_user_already_added_error_when_user_already_on_hold( + sdk, user_already_added_response +): + sdk.legalhold.add_to_matter.side_effect = user_already_added_response + with pytest.raises(UserAlreadyAddedError): + add_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) + + +def test_add_user_raises_legalhold_not_found_error_if_matter_inaccessible( + sdk, check_matter_accessible_failure, get_user_id_success +): + with pytest.raises(LegalHoldNotFoundOrPermissionDeniedError): + add_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) + + +def test_add_user_adds_user_to_hold_if_user_and_matter_exist( + sdk, check_matter_accessible_success, get_user_id_success +): + add_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) + sdk.legalhold.add_to_matter.assert_called_once_with(ACTIVE_TEST_USER_ID, TEST_MATTER_ID) + + +def test_remove_user_raises_legalhold_not_found_error_if_matter_inaccessible( + sdk, check_matter_accessible_failure, get_user_id_success +): + with pytest.raises(LegalHoldNotFoundOrPermissionDeniedError): + remove_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) + + +def test_remove_user_raises_user_not_in_matter_error_if_user_not_active_in_matter( + sdk, check_matter_accessible_success, get_user_id_success +): + sdk.legalhold.get_all_matter_custodians.return_value = EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT + with pytest.raises(UserNotInLegalHoldError): + remove_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) + + +def test_remove_user_removes_user_if_user_in_matter( + sdk, check_matter_accessible_success, get_user_id_success +): + sdk.legalhold.get_all_matter_custodians.return_value = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + membership_uid = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT[0]["legalHoldMemberships"][0][ + "legalHoldMembershipUid" + ] + remove_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) + sdk.legalhold.remove_from_matter.assert_called_with(membership_uid) + + +def test_matter_accessible_check_only_makes_one_http_call_when_called_multiple_times_with_same_matter_id( + sdk, check_matter_accessible_success +): + _check_matter_is_accessible(sdk, TEST_MATTER_ID) + _check_matter_is_accessible(sdk, TEST_MATTER_ID) + _check_matter_is_accessible(sdk, TEST_MATTER_ID) + _check_matter_is_accessible(sdk, TEST_MATTER_ID) + assert sdk.legalhold.get_matter_by_uid.call_count == 1 + + +def test_show_matter_prints_active_and_inactive_results_when_include_inactive_flag_set( + sdk, check_matter_accessible_success, capsys +): + sdk.legalhold.get_all_matter_custodians.return_value = ( + ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + show_matter(sdk, TEST_MATTER_ID, include_inactive=True) + capture = capsys.readouterr() + assert ACTIVE_TEST_USERNAME in capture.out + assert INACTIVE_TEST_USERNAME in capture.out + + +def test_show_matter_prints_active_results_only(sdk, check_matter_accessible_success, capsys): + sdk.legalhold.get_all_matter_custodians.return_value = ( + ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + show_matter(sdk, TEST_MATTER_ID) + capture = capsys.readouterr() + assert ACTIVE_TEST_USERNAME in capture.out + assert INACTIVE_TEST_USERNAME not in capture.out + + +def test_show_matter_prints_no_active_members_when_no_membership( + sdk, check_matter_accessible_success, capsys +): + sdk.legalhold.get_all_matter_custodians.return_value = EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT + show_matter(sdk, TEST_MATTER_ID) + capture = capsys.readouterr() + assert ACTIVE_TEST_USERNAME not in capture.out + assert INACTIVE_TEST_USERNAME not in capture.out + assert "No active matter members." in capture.out + + +def test_show_matter_prints_no_inactive_members_when_no_inactive_membership( + sdk, check_matter_accessible_success, capsys +): + sdk.legalhold.get_all_matter_custodians.return_value = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + show_matter(sdk, TEST_MATTER_ID, include_inactive=True) + capture = capsys.readouterr() + assert ACTIVE_TEST_USERNAME in capture.out + assert INACTIVE_TEST_USERNAME not in capture.out + assert "No inactive matter members." in capture.out + + +def test_show_matter_prints_no_active_members_when_no_active_membership( + sdk, check_matter_accessible_success, capsys +): + sdk.legalhold.get_all_matter_custodians.return_value = INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + show_matter(sdk, TEST_MATTER_ID, include_inactive=True) + capture = capsys.readouterr() + assert ACTIVE_TEST_USERNAME not in capture.out + assert INACTIVE_TEST_USERNAME in capture.out + assert "No active matter members." in capture.out + + +def test_show_matter_prints_no_active_members_when_no_active_membership_and_inactive_membership_included( + sdk, check_matter_accessible_success, capsys +): + sdk.legalhold.get_all_matter_custodians.return_value = INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + show_matter(sdk, TEST_MATTER_ID, include_inactive=True) + capture = capsys.readouterr() + assert ACTIVE_TEST_USERNAME not in capture.out + assert INACTIVE_TEST_USERNAME in capture.out + assert "No active matter members." in capture.out + + +def test_show_matter_prints_preservation_policy_when_include_policy_flag_set( + sdk, check_matter_accessible_success, preservation_policy_response, capsys +): + sdk.legalhold.get_policy_by_uid.return_value = preservation_policy_response + show_matter(sdk, TEST_MATTER_ID, include_policy=True) + capture = capsys.readouterr() + assert TEST_PRESERVATION_POLICY_UID in capture.out + + +def test_show_matter_does_not_print_preservation_policy( + sdk, check_matter_accessible_success, preservation_policy_response, capsys +): + sdk.legalhold.get_policy_by_uid.return_value = preservation_policy_response + show_matter(sdk, TEST_MATTER_ID) + capture = capsys.readouterr() + assert TEST_PRESERVATION_POLICY_UID not in capture.out + + +def test_add_bulk_users_uses_expected_arguments(mocker, sdk, profile): + reader = create_mock_reader([{"test": "value"}]) + bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) + reader_factory.return_value = reader + add_bulk_users(sdk, "csv_test") + assert bulk_processor.call_args[0][1] == reader + reader_factory.assert_called_once_with("csv_test") + + +def test_remove_bulk_users_uses_expected_arguments(mocker, sdk, profile): + reader = create_mock_reader([{"test": "value"}]) + bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) + reader_factory.return_value = reader + remove_bulk_users(sdk, "csv_test") + assert bulk_processor.call_args[0][1] == reader + reader_factory.assert_called_once_with("csv_test") diff --git a/tests/cmds/securitydata/test_cursor_store.py b/tests/cmds/securitydata/test_cursor_store.py index 9a2c581d9..602530a52 100644 --- a/tests/cmds/securitydata/test_cursor_store.py +++ b/tests/cmds/securitydata/test_cursor_store.py @@ -9,9 +9,9 @@ def test_init_cursor_store_when_not_given_db_file_path_uses_expected_default_che self, sqlite_connection ): home_dir = path.expanduser("~") - expected_path = path.join(home_dir, ".code42cli/db") + expected_path = path.join(home_dir, ".code42cli", "db") db_table_name = "TEST" - expected_db_file_path = "{0}/file_event_checkpoints.db".format(expected_path) + expected_db_file_path = path.join(expected_path, "file_event_checkpoints.db") BaseCursorStore(db_table_name) sqlite_connection.assert_called_once_with(expected_db_file_path) diff --git a/tests/conftest.py b/tests/conftest.py index 6adedbc42..d776aed64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import pytest from py42.sdk import SDKClient +from code42cli.bulk import BulkProcessor from code42cli.file_readers import CliFileReader from code42cli.config import ConfigAccessor from code42cli.profile import Code42Profile @@ -91,6 +92,21 @@ def sdk(mocker): return mocker.MagicMock(spec=SDKClient) +TEST_ID = "TEST_ID" + + +@pytest.fixture +def sdk_with_user(sdk): + sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_ID}]} + return sdk + + +@pytest.fixture +def sdk_without_user(sdk): + sdk.users.get_by_username.return_value = {"users": []} + return sdk + + @pytest.fixture() def mock_42(mocker): return mocker.patch("py42.sdk.from_local_account") diff --git a/tests/test_commands.py b/tests/test_commands.py index 8645a87fd..4cd732c14 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -21,7 +21,7 @@ subcommand3 = Command("sub3", "sub3 desc", "sub3 usage") -class TestSubcommandLoader(SubcommandLoader): +class DummySubcommandLoader(SubcommandLoader): def load_commands(self): return [subcommand1, subcommand2, subcommand3] @@ -57,7 +57,7 @@ def test_usage(self): def test_load_subcommands_makes_subcommands_accessible(self): command = Command( - "test", "test desc", "test usage", subcommand_loader=TestSubcommandLoader("test") + "test", "test desc", "test usage", subcommand_loader=DummySubcommandLoader("test") ) command.load_subcommands() assert len(command.subcommands) == 3 @@ -298,6 +298,6 @@ def test_subtrees_returns_expected_substree(self): Command("c2", ""), Command("c3", ""), ] - command = Command("c1", "", subcommand_loader=TestSubcommandLoader("")) + command = Command("c1", "", subcommand_loader=DummySubcommandLoader("")) subcommand_loader.load_commands = lambda: [command] assert subcommand_loader.subtrees diff --git a/tests/test_main.py b/tests/test_main.py index b23f66a78..990dfdae4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -40,3 +40,8 @@ def test_high_risk_employee_commands_load(capsys, mocker): def test_alert_rules_commands_load(capsys, mocker): mocker.patch("sys.argv", [u"code42", u"alert-rules", u"bulk", u"add", u"-h"]) _execute_test(capsys, u"add") + + +def test_legal_hold_commands_load(capsys, mocker): + mocker.patch("sys.argv", [u"code42", u"legal-hold", u"bulk", u"add", u"-h"]) + _execute_test(capsys, u"bulk") diff --git a/tests/test_util.py b/tests/test_util.py index 8515d90f8..753eacc0d 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,7 +1,8 @@ import pytest from code42cli import PRODUCT_NAME -from code42cli.util import does_user_agree, get_url_parts, find_format_width +from code42cli.util import does_user_agree, get_url_parts, find_format_width, get_user_id +from code42cli.errors import UserDoesNotExistError TEST_HEADER = {u"key1": u"Column 1", u"key2": u"Column 10", u"key3": u"Column 100"} @@ -69,3 +70,8 @@ def test_find_format_width_filters_keys_not_present_in_header(): result, _ = find_format_width(report, header_with_subset_keys) for item in result: assert u"key2" not in item.keys() + + +def test_get_user_id_when_user_does_not_raise_error(sdk_without_user): + with pytest.raises(UserDoesNotExistError): + get_user_id(sdk_without_user, "risky employee") diff --git a/tox.ini b/tox.ini index 732642260..f3269b9f6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = clean,py27,py35,py36,py37,py38,report,lint27,lint37 +envlist = clean,py35,py36,py37,py38,report,lint37 # don't require all versions of python to be installed to run tests. # the github workflow ensures that this is run with each necessary python version. @@ -21,8 +21,8 @@ commands = pytest --cov=code42cli --cov-append -v -rsxX -l --tb=short --strict --disable-pytest-warnings depends = - {py27,py35,py36,py37,py38}: clean - report: py27,py35,py36,py37,py38 + {py35,py36,py37,py38}: clean + report: py35,py36,py37,py38 [testenv:report] deps = coverage @@ -36,11 +36,6 @@ deps = coverage skip_install = true commands = coverage erase -[testenv:lint27] -basepython = python2.7 -deps = pylint==1.9.5 -commands = pylint -E code42cli - [testenv:lint37] basepython = python3.7 deps = pylint==2.4.0 From 7d52e47c764d1686ac839a0cc209d6e34040ca8a Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Tue, 2 Jun 2020 10:39:14 -0500 Subject: [PATCH 072/349] bump (#92) --- src/code42cli/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 5de1738a2..c7c63e25d 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.6.0b1" +__version__ = "0.6.0b2" From 5b351b6c923a2aaf2b0ed1f6ab26f8e962c843b2 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 3 Jun 2020 15:58:12 -0500 Subject: [PATCH 073/349] handle SIGPIPE errors when piping output to another process (#73) * handle SIGPIPE errors when piping output to another process * fix SIGINT handling within threaded queue * attempt to fix CI tests * remove test print * another attempt at CI tests fix * fix handler to store cursor in handle_results * use queue.join() unless we're running on python2 * remove SIGPIPE handling * - fix logging stacks - fix printed exception on broken pipe due to stdout being closed prematurely * just catch broken pipe so we don't close stdout/err on tests * remove py2 workaround from Worker * - apparently queue.join() doesn't handle SIGINT on Windows even on py3 - print on exit to not leave progress bar on current line * uncomment flush func --- src/code42cli/cmds/alerts/extraction.py | 2 +- .../cmds/search_shared/extraction.py | 11 +++++---- src/code42cli/cmds/securitydata/extraction.py | 2 +- src/code42cli/logger.py | 2 ++ src/code42cli/main.py | 7 +++++- src/code42cli/util.py | 23 ++++++++++++++++--- src/code42cli/worker.py | 4 +++- 7 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/code42cli/cmds/alerts/extraction.py b/src/code42cli/cmds/alerts/extraction.py index e3adc0563..283c0809a 100644 --- a/src/code42cli/cmds/alerts/extraction.py +++ b/src/code42cli/cmds/alerts/extraction.py @@ -37,7 +37,7 @@ def extract(sdk, profile, output_logger, args): args: Command line args used to build up alert query filters. """ store = AlertCursorStore(profile.name) if args.incremental else None - handlers = create_handlers(output_logger, store, event_key=u"alerts", sdk=sdk) + handlers = create_handlers(sdk, AlertExtractor, output_logger, store) extractor = AlertExtractor(sdk, handlers) if args.advanced_query: exit_if_advanced_query_used_with_other_search_args(args) diff --git a/src/code42cli/cmds/search_shared/extraction.py b/src/code42cli/cmds/search_shared/extraction.py index 08641ce85..522d76f11 100644 --- a/src/code42cli/cmds/search_shared/extraction.py +++ b/src/code42cli/cmds/search_shared/extraction.py @@ -33,7 +33,8 @@ def verify_begin_date_requirements(args, cursor_store): exit(1) -def create_handlers(output_logger, cursor_store, event_key, sdk=None): +def create_handlers(sdk, extractor_class, output_logger, cursor_store): + extractor = extractor_class(sdk, ExtractionHandlers()) handlers = ExtractionHandlers() handlers.TOTAL_EVENTS = 0 @@ -52,12 +53,12 @@ def handle_error(exception): handlers.get_cursor_position = cursor_store.get_stored_cursor_timestamp @warn_interrupt( - warning=u"Cancelling operation cleanly to keep checkpoint data accurate. One moment..." + warning=u"Attempting to cancel cleanly to keep checkpoint data accurate. One moment..." ) def handle_response(response): response_dict = json.loads(response.text) - events = response_dict.get(event_key) - if event_key == u"alerts": + events = response_dict.get(extractor._key) + if extractor._key == u"alerts": try: events = get_alert_details(sdk, events) except Exception as ex: @@ -65,6 +66,8 @@ def handle_response(response): handlers.TOTAL_EVENTS += len(events) for event in events: output_logger.info(event) + last_event_timestamp = extractor._get_timestamp_from_item(event) + handlers.record_cursor_position(last_event_timestamp) handlers.handle_response = handle_response return handlers diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index 6a0e43b02..cd45ef4db 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -28,7 +28,7 @@ def extract(sdk, profile, output_logger, args): args: Command line args used to build up file event query filters. """ store = FileEventCursorStore(profile.name) if args.incremental else None - handlers = create_handlers(output_logger, store, event_key=u"fileEvents") + handlers = create_handlers(sdk, FileEventExtractor, output_logger, store) extractor = FileEventExtractor(sdk, handlers) if args.advanced_query: exit_if_advanced_query_used_with_other_search_args(args) diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index e64264546..a7ef4494b 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -6,6 +6,8 @@ from code42cli.compat import str from code42cli.util import get_user_project_path, is_interactive, color_text_red +# prevent loggers from printing stacks to stderr if a pipe is broken +logging.raiseExceptions = False logger_deps_lock = Lock() ERROR_LOG_FILE_NAME = u"code42_errors.log" diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 0b8c08b07..97c741075 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -15,10 +15,12 @@ from code42cli.cmds.profile import ProfileSubcommandLoader from code42cli.commands import Command, SubcommandLoader from code42cli.invoker import CommandInvoker +from code42cli.util import flush_stds_out_err_without_printing_error # Handle KeyboardInterrupts by just exiting instead of printing out a stack def exit_on_interrupt(signal, frame): + print() sys.exit(1) @@ -118,7 +120,10 @@ def _create_legal_hold_loader(self): def main(): top = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) invoker = CommandInvoker(top) - invoker.run(sys.argv[1:]) + try: + invoker.run(sys.argv[1:]) + finally: + flush_stds_out_err_without_printing_error() if __name__ == u"__main__": diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 46a9e7fac..3c1f97d37 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -50,6 +50,22 @@ def is_interactive(): return sys.stdin.isatty() +def flush_stds_out_err_without_printing_error(): + """Workaround for bug in python3 that causes exception to be printed on broken pipe: + https://bugs.python.org/issue11380 + """ + try: + sys.stdout.flush() + except BrokenPipeError: + try: + sys.stdout.close() + except BrokenPipeError: + try: + sys.stderr.flush() + except BrokenPipeError: + sys.stderr.close() + + def get_url_parts(url_str): parts = url_str.split(u":") port = None @@ -129,19 +145,20 @@ def my_important_func(): def __init__(self, warning="Cancelling operation cleanly, one moment... "): self.warning = warning - self.old_handler = None + self.old_int_handler = None self.interrupted = False self.exit_instructions = "Hit CTRL-C again to force quit." def __enter__(self): - self.old_handler = getsignal(SIGINT) + self.old_int_handler = getsignal(SIGINT) signal(SIGINT, self._handle_interrupts) return self def __exit__(self, exc_type, exc_val, exc_tb): if self.interrupted: exit(1) - signal(SIGINT, self.old_handler) + signal(SIGINT, self.old_int_handler) + return False def _handle_interrupts(self, sig, frame): diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 8c6728bb1..0e6035508 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -1,4 +1,5 @@ from threading import Thread, Lock +from time import sleep from py42.exceptions import Py42HTTPError, Py42ForbiddenError @@ -81,7 +82,8 @@ def stats(self): def wait(self): """Wait for the tasks in the queue to complete. This should usually be called before program termination.""" - self._queue.join() + while not self._queue.empty(): + sleep(0.5) def _process_queue(self): while True: From 670c8d501b933e14d2b314688ca836e4c6fdc5d5 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 4 Jun 2020 07:54:05 -0500 Subject: [PATCH 074/349] Bugfix/search arg validation (#93) * check against specific search_term enum instead of specified allowlist * disallow begin/end/incremental * remove unecessary changes * remove unecessary changes * remove unecessary changes * get incompatible arg list from actual argument dict keys * test and an h * remove unused arg --- src/code42cli/cmds/alerts/extraction.py | 2 +- src/code42cli/cmds/search_shared/args.py | 38 ++++++++++++------- .../cmds/search_shared/extraction.py | 18 +++++---- src/code42cli/cmds/securitydata/extraction.py | 7 +++- .../search_shared/test_advanced_query_args.py | 25 ++++++++++++ 5 files changed, 66 insertions(+), 24 deletions(-) create mode 100644 tests/cmds/search_shared/test_advanced_query_args.py diff --git a/src/code42cli/cmds/alerts/extraction.py b/src/code42cli/cmds/alerts/extraction.py index 283c0809a..caac70d1c 100644 --- a/src/code42cli/cmds/alerts/extraction.py +++ b/src/code42cli/cmds/alerts/extraction.py @@ -40,7 +40,7 @@ def extract(sdk, profile, output_logger, args): handlers = create_handlers(sdk, AlertExtractor, output_logger, store) extractor = AlertExtractor(sdk, handlers) if args.advanced_query: - exit_if_advanced_query_used_with_other_search_args(args) + exit_if_advanced_query_used_with_other_search_args(args, enums.AlertFilterArguments()) extractor.extract_advanced(args.advanced_query) else: verify_begin_date_requirements(args, store) diff --git a/src/code42cli/cmds/search_shared/args.py b/src/code42cli/cmds/search_shared/args.py index df1c96f8e..f1a4db05c 100644 --- a/src/code42cli/cmds/search_shared/args.py +++ b/src/code42cli/cmds/search_shared/args.py @@ -3,7 +3,12 @@ def create_search_args(search_for, filter_args): - search_args = { + advanced_query_incompatible_args = create_advanced_query_incompatible_search_args(search_for) + filter_args.update(advanced_query_incompatible_args) + + format_enum = AlertOutputFormat() if search_for == "alerts" else OutputFormat() + + advanced_query_compatible_args = { SearchArguments.ADVANCED_QUERY: ArgConfig( u"--{}".format(SearchArguments.ADVANCED_QUERY.replace(u"_", u"-")), metavar=u"QUERY_JSON", @@ -13,6 +18,23 @@ def create_search_args(search_for, filter_args): search_for ), ), + u"format": ArgConfig( + u"-f", + u"--format", + choices=format_enum, + default=format_enum.JSON, + help=u"The format used for outputting {0}.".format(search_for), + ), + } + filter_args.update(advanced_query_compatible_args) + + return filter_args + + +def create_advanced_query_incompatible_search_args(search_for=None): + """Returns a dict of args that are incompatible with the --advanced-query flag. Any new + incompatible args should go here as this is function is also used for arg validation.""" + args = { SearchArguments.BEGIN_DATE: ArgConfig( u"-b", u"--{}".format(SearchArguments.BEGIN_DATE), @@ -30,16 +52,6 @@ def create_search_args(search_for, filter_args): help=u"The end of the date range in which to look for {0}, " u"argument format options are the same as --begin.".format(search_for), ), - } - format_enum = AlertOutputFormat() if search_for == "alerts" else OutputFormat() - format_and_incremental_args = { - u"format": ArgConfig( - u"-f", - u"--format", - choices=format_enum, - default=format_enum.JSON, - help=u"The format used for outputting {0}.".format(search_for), - ), u"incremental": ArgConfig( u"-i", u"--incremental", @@ -47,6 +59,4 @@ def create_search_args(search_for, filter_args): help=u"Only get {0} that were not previously retrieved.".format(search_for), ), } - search_args.update(filter_args) - search_args.update(format_and_incremental_args) - return search_args + return args diff --git a/src/code42cli/cmds/search_shared/extraction.py b/src/code42cli/cmds/search_shared/extraction.py index 522d76f11..898c9c131 100644 --- a/src/code42cli/cmds/search_shared/extraction.py +++ b/src/code42cli/cmds/search_shared/extraction.py @@ -8,6 +8,7 @@ from code42cli.logger import get_main_cli_logger from code42cli.cmds.alerts.util import get_alert_details from code42cli.util import warn_interrupt +from code42cli.cmds.search_shared.args import create_advanced_query_incompatible_search_args logger = get_main_cli_logger() @@ -73,13 +74,16 @@ def handle_response(response): return handlers -def exit_if_advanced_query_used_with_other_search_args(args): - args_dict_copy = args.__dict__.copy() - for arg in (u"advanced_query", u"format", u"sdk", u"profile"): - args_dict_copy.pop(arg) - if any(args_dict_copy.values()): - logger.print_and_log_error(u"You cannot use --advanced-query with additional search args.") - exit(1) +def exit_if_advanced_query_used_with_other_search_args(args, search_arg_enum): + incompatible_search_args_dict = create_advanced_query_incompatible_search_args() + incompatible_search_args_list = list(incompatible_search_args_dict.keys()) + invalid_args = incompatible_search_args_list + list(search_arg_enum) + for arg in invalid_args: + if args.__dict__[arg]: + logger.print_and_log_error( + u"You cannot use --advanced-query with additional search args." + ) + exit(1) def create_time_range_filter(filter_cls, begin_date=None, end_date=None): diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index cd45ef4db..99e13661e 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -1,7 +1,10 @@ from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.filters import * -from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions +from code42cli.cmds.search_shared.enums import ( + ExposureType as ExposureTypeOptions, + FileEventFilterArguments, +) from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore from code42cli.cmds.search_shared.extraction import ( verify_begin_date_requirements, @@ -31,7 +34,7 @@ def extract(sdk, profile, output_logger, args): handlers = create_handlers(sdk, FileEventExtractor, output_logger, store) extractor = FileEventExtractor(sdk, handlers) if args.advanced_query: - exit_if_advanced_query_used_with_other_search_args(args) + exit_if_advanced_query_used_with_other_search_args(args, FileEventFilterArguments()) extractor.extract_advanced(args.advanced_query) else: verify_begin_date_requirements(args, store) diff --git a/tests/cmds/search_shared/test_advanced_query_args.py b/tests/cmds/search_shared/test_advanced_query_args.py new file mode 100644 index 000000000..685f487ab --- /dev/null +++ b/tests/cmds/search_shared/test_advanced_query_args.py @@ -0,0 +1,25 @@ +import pytest +from code42cli.cmds.search_shared.extraction import ( + exit_if_advanced_query_used_with_other_search_args, +) +from code42cli.cmds.search_shared.enums import FileEventFilterArguments, AlertFilterArguments + + +def test_exit_if_advanced_query_provided_incompatible_args( + mocker, file_event_namespace, alert_namespace +): + mock = mocker.patch( + "code42cli.cmds.search_shared.extraction.create_advanced_query_incompatible_search_args" + ) + mock.return_value = { + "invalid_arg": None, + } + file_event_namespace.invalid_arg = "value" + with pytest.raises(SystemExit): + exit_if_advanced_query_used_with_other_search_args( + file_event_namespace, FileEventFilterArguments() + ) + + alert_namespace.invalid_arg = "value" + with pytest.raises(SystemExit): + exit_if_advanced_query_used_with_other_search_args(alert_namespace, AlertFilterArguments()) From 70c6b08f1bcdefc5fa91f8bab60062f222da29f7 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 4 Jun 2020 09:55:50 -0500 Subject: [PATCH 075/349] New name in links (#94) --- docs/userguides/gettingstarted.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index 863c2a77f..16dd21f32 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -7,7 +7,7 @@ ## Licensing -This project uses the [MIT License](https://github.com/code42/c42sec/blob/master/LICENSE.md). +This project uses the [MIT License](https://github.com/code42/code42cli/blob/master/LICENSE.md). ## Installation @@ -32,10 +32,10 @@ Visit the [project history](https://pypi.org/project/code42cli/#history) on PyPI ### From source -Alternatively, you can install the Code42 CLI directly from [source code](https://github.com/code42/c42sec): +Alternatively, you can install the Code42 CLI directly from [source code](https://github.com/code42/code42cli): ```bash -git clone https://github.com/code42/c42sec.git +git clone https://github.com/code42/code42cli.git ``` When it finishes downloading, from the root project directory, run: @@ -95,7 +95,7 @@ code42 -d ### File an issue on GitHub If you are experiencing an issue with the Code42 CLI, you can create a *New issue* at the -[project repository](https://github.com/code42/c42sec/issues). See the Github +[project repository](https://github.com/code42/code42cli/issues). See the Github [guide on creating an issue](https://help.github.com/en/github/managing-your-work-on-github/creating-an-issue) for more information. ### Contact Code42 Support From 6619fdf3a3e2daacacfbfa1bb1786d11654d868d Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Mon, 8 Jun 2020 12:59:21 -0500 Subject: [PATCH 076/349] bump (#96) --- CHANGELOG.md | 4 +++- src/code42cli/__version__.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec3bae0c..685e52284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 0.7.0 - 2020-06-08 ### Changed +- `code42cli` no longer supports python 2.7. + - `code42 profile create` now uses required `--name`, `--server` and `--username` flags instead of positional arguments. - `code42 high-risk-employee add-risk-tags` now uses required `--username` and `--tag` flags instead of positional arguments. diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index c7c63e25d..49e0fc1e0 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.6.0b2" +__version__ = "0.7.0" From 7b9b2329f81e8f27e1b4c3cb0872087a8a2576e3 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Wed, 10 Jun 2020 11:55:15 -0500 Subject: [PATCH 077/349] Bugfix/max page and alert id list (#99) --- CHANGELOG.md | 6 ++++++ setup.py | 2 +- src/code42cli/__version__.py | 2 +- src/code42cli/cmds/alerts/util.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 685e52284..b2c056df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 0.7.1 - 2020-06-10 + +### Fixed + +- Issue that prevented alerts from being retrieved successfully via `code42 alerts` commands due to a change in its backing API. + ## 0.7.0 - 2020-06-08 ### Changed diff --git a/setup.py b/setup.py index 819d0ceaa..64e4e16fd 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ package_dir={"": "src"}, python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ - "c42eventextractor==0.3.1", + "c42eventextractor==0.3.2", "keyring==18.0.1", "keyrings.alt==3.2.0", "py42>=1.2.0", diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 49e0fc1e0..a5f830a2c 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/src/code42cli/cmds/alerts/util.py b/src/code42cli/cmds/alerts/util.py index fd63200fb..048fdabab 100644 --- a/src/code42cli/cmds/alerts/util.py +++ b/src/code42cli/cmds/alerts/util.py @@ -1,6 +1,6 @@ from code42cli.compat import range -_BATCH_SIZE = 500 +_BATCH_SIZE = 100 def get_alert_details(sdk, alert_summary_list): From ed7e367731bf61a23727c22154d96f6bcf19801f Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 11 Jun 2020 12:35:07 -0500 Subject: [PATCH 078/349] Complete Completion (#88) --- CHANGELOG.md | 2 + src/code42cli/cmds/alerts/rules/commands.py | 2 +- src/code42cli/cmds/detectionlists/__init__.py | 14 +-- src/code42cli/cmds/detectionlists/commands.py | 12 +- src/code42cli/cmds/legal_hold/__init__.py | 8 +- src/code42cli/cmds/securitydata/main.py | 1 - src/code42cli/commands.py | 32 +++--- src/code42cli/completer.py | 55 ++++++--- src/code42cli/main.py | 5 +- src/code42cli/tree_nodes.py | 104 ++++++++++++++++++ src/code42cli/util.py | 32 ++++-- tests/cmds/alerts/test_extraction.py | 1 - tests/conftest.py | 12 +- tests/test_commands.py | 26 ++--- tests/test_completer.py | 79 ++++++++++++- tests/test_invoker.py | 6 +- tests/test_main.py | 30 ++++- tests/test_parser.py | 4 +- tests/test_tree_nodes.py | 63 +++++++++++ 19 files changed, 396 insertions(+), 92 deletions(-) create mode 100644 src/code42cli/tree_nodes.py create mode 100644 tests/test_tree_nodes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c056df5..17c176a8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Short option `-u` added for `code42 high-risk-employee add-risk-tags` and `remove-risk-tags`. +- Tab completion for bash and zsh for Unix based machines. + ### Fixed - Fixed bug in bulk commands where value-less fields in csv files were treated as empty strings instead of None. diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py index 0a2fae2dc..43d1c97d6 100644 --- a/src/code42cli/cmds/alerts/rules/commands.py +++ b/src/code42cli/cmds/alerts/rules/commands.py @@ -131,7 +131,7 @@ def load_commands(self): add = Command( self.ADD_USER, u"Add a user to an alert rule.", - u"{} add-user --rule-id --username ".format(usage_prefix), + u"{} add-user --rule-id --username ".format(usage_prefix), handler=add_user, arg_customizer=_customize_add_arguments, ) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 854eb5ef7..a80d6cff4 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -144,16 +144,16 @@ def generate_template_file(self, cmd, path=None): handler = detection_list.get_handler(self.handlers, cmd) generate_template(handler, path) - def bulk_add_employees(self, sdk, profile, csv_file): + def bulk_add_employees(self, sdk, profile, filename): """Takes a csv file with each row representing an employee and adds them all to a detection list in a bulk fashion. Args: sdk (py42.sdk.SDKClient): The py42 sdk. profile (Code42Profile): The profile under which to execute this command. - csv_file (str or unicode): The path to the csv file containing rows of users. + filename (str or unicode): The path to the csv file containing rows of users. """ - reader = create_csv_reader(csv_file) + reader = create_csv_reader(filename) run_bulk_process(lambda **kwargs: self._add_employee(sdk, profile, **kwargs), reader) def bulk_remove_employees(self, sdk, profile, users_file): @@ -176,12 +176,12 @@ def _add_employee(self, sdk, profile, **kwargs): def _remove_employee(self, sdk, profile, *args, **kwargs): self.handlers.remove_employee(sdk, profile, *args, **kwargs) - def bulk_add_risk_tags(self, sdk, profile, csv_file): - reader = create_csv_reader(csv_file) + def bulk_add_risk_tags(self, sdk, profile, filename): + reader = create_csv_reader(filename) run_bulk_process(lambda **kwargs: add_risk_tags(sdk, profile, **kwargs), reader) - def bulk_remove_risk_tags(self, sdk, profile, csv_file): - reader = create_csv_reader(csv_file) + def bulk_remove_risk_tags(self, sdk, profile, filename): + reader = create_csv_reader(filename) run_bulk_process(lambda **kwargs: remove_risk_tags(sdk, profile, **kwargs), reader) diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py index 63f094571..077c7a504 100644 --- a/src/code42cli/cmds/detectionlists/commands.py +++ b/src/code42cli/cmds/detectionlists/commands.py @@ -146,8 +146,8 @@ def _load_hre_bulk_generate_template_description(argument_collection): cmd_type.set_choices(HighRiskBulkCommandType()) def _load_bulk_add_description(self, argument_collection): - csv_file = argument_collection.arg_configs[u"csv_file"] - csv_file.set_help( + filename = argument_collection.arg_configs[u"filename"] + filename.set_help( u"The path to the csv file for bulk adding users to the {} detection list.".format( self._name ) @@ -162,16 +162,16 @@ def _load_bulk_remove_description(self, argument_collection): ) def _load_bulk_add_risk_tags_description(self, argument_collection): - csv_file = argument_collection.arg_configs[u"csv_file"] - csv_file.set_help( + filename = argument_collection.arg_configs[u"filename"] + filename.set_help( u"A file containing a ',' separated username with space-separated tags to add " u"to the {} detection list. " u"e.g. test@email.com,tag1 tag2 tag3".format(self._name) ) def _load_bulk_remove_risk_tags_description(self, argument_collection): - csv_file = argument_collection.arg_configs[u"csv_file"] - csv_file.set_help( + filename = argument_collection.arg_configs[u"filename"] + filename.set_help( u"A file containing a ',' separated username with space-separated tags to remove " u"from the {} detection list. " u"e.g. test@email.com,tag1 tag2 tag3".format(self._name) diff --git a/src/code42cli/cmds/legal_hold/__init__.py b/src/code42cli/cmds/legal_hold/__init__.py index 28f509cdc..81b806e41 100644 --- a/src/code42cli/cmds/legal_hold/__init__.py +++ b/src/code42cli/cmds/legal_hold/__init__.py @@ -59,16 +59,12 @@ def get_matters(sdk): def add_bulk_users(sdk, file_name): reader = create_csv_reader(file_name) - run_bulk_process( - lambda matter_id, username: add_user(sdk, matter_id, username), reader, - ) + run_bulk_process(lambda matter_id, username: add_user(sdk, matter_id, username), reader) def remove_bulk_users(sdk, file_name): reader = create_csv_reader(file_name) - run_bulk_process( - lambda matter_id, username: remove_user(sdk, matter_id, username), reader, - ) + run_bulk_process(lambda matter_id, username: remove_user(sdk, matter_id, username), reader) def show_matter(sdk, matter_id, include_inactive=False, include_policy=False): diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index c8c6d63c3..6ad38e4f3 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -100,7 +100,6 @@ def _load_send_to_args(arg_collection): help=u"Protocol used to send logs to server.", ), } - arg_collection.extend(send_to_args) _load_search_args(arg_collection) diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py index 14ee8e074..3edc6bfdb 100644 --- a/src/code42cli/commands.py +++ b/src/code42cli/commands.py @@ -3,6 +3,7 @@ from code42cli import profile as cliprofile from code42cli.args import get_auto_arg_configs, SDK_ARG_NAME, PROFILE_ARG_NAME from code42cli.sdk_client import create_sdk +from code42cli.tree_nodes import SubcommandNode class DictObject(object): @@ -150,29 +151,24 @@ def _kvps_to_obj(kvps): class SubcommandLoader(object): - """Responsible for creating subcommands for it's root command. It is also useful for getting - command information ahead of time, as in the example of tab completion.""" + """Responsible for creating subcommands for it's root command.""" - def __init__(self, root_command_name): + def __init__(self, root_command_name, node=None): self.root = root_command_name + self._node = node - @property - def names(self): - """The names of all the subcommands in this subcommabd loader's root command.""" - sub_cmds = self.load_commands() - return [cmd.name for cmd in sub_cmds] + def __getitem__(self, item): + return self.get_node()[item] @property - def subtrees(self): - """All subcommands for this subcommand loader's root command mapped to their given - subcommand loaders.""" - cmds = self.load_commands() - results = {} - for cmd in cmds: - subcommand_loader = cmd.subcommand_loader - if subcommand_loader: - results[cmd.name] = subcommand_loader - return results + def names(self): + return self.get_node().names def load_commands(self): + """Override""" return [] + + def get_node(self): + if not self._node: + self._node = SubcommandNode(self.root, self.load_commands()) + return self._node diff --git a/src/code42cli/completer.py b/src/code42cli/completer.py index d69c6ff67..f4ac3f8be 100644 --- a/src/code42cli/completer.py +++ b/src/code42cli/completer.py @@ -1,5 +1,9 @@ +from os import path + from code42cli import MAIN_COMMAND from code42cli.main import MainSubcommandLoader +from code42cli.tree_nodes import ArgNode +from code42cli.util import get_files_in_path def _get_matches(current, options): @@ -11,32 +15,45 @@ def _get_matches(current, options): return matches -def _get_next_full_set_of_commands(cmd_loader, current): - cmd_loader = cmd_loader.subtrees[current] - return cmd_loader.names +def _get_next_full_set_of_options(node, current): + node = node[current] + names = list(node.names) + if _can_complete_with_local_files(current, node): + files = get_files_in_path("") + names.extend(files) + return names + + +def _can_complete_with_local_files(current, node): + return isinstance(node, ArgNode) and (not current or current[0] != u"-") class Completer(object): def __init__(self, main_cmd_loader=None): - self._main_cmd_loader = main_cmd_loader or MainSubcommandLoader(u"") + self._main_cmd_loader = main_cmd_loader or MainSubcommandLoader() def complete(self, cmdline, point=None): try: point = point or len(cmdline) args = cmdline[0:point].split() + # Complete with main commands if `code42` is typed out. + # Note that the command `code42` should complete on its own. if len(args) < 2: - # `code42` already completes w/o return self._main_cmd_loader.names if args[0] == MAIN_COMMAND else [] current = args[-1] - cmd_loader = self._search_trees(args) - if not cmd_loader: - return [] + search_results, options = self._get_completion_options(args) - options = cmd_loader.names + # Complete with full set of arg/command options if current in options: - # `current` is already complete - return _get_next_full_set_of_commands(cmd_loader, current) + return _get_next_full_set_of_options(search_results, current) + + if _can_complete_with_local_files(current, search_results): + files = get_files_in_path(current) + if current[0] == "~": + replace = path.expanduser("~") + files = [f.replace(replace, "~") for f in files] + options.extend(files) return _get_matches(current, options) if options else [] except: @@ -44,13 +61,21 @@ def complete(self, cmdline, point=None): def _search_trees(self, args): # Find cmd_loader at lowest level from given args - cmd_loader = self._main_cmd_loader + node = self._main_cmd_loader.get_node() if len(args) > 2: for arg in args[1:-1]: - cmd_loader = cmd_loader.subtrees[arg] - return cmd_loader + next_node = node[arg] + if next_node: + node = next_node + else: + return node + return node + + def _get_completion_options(self, args): + search_results = self._search_trees(args) + return search_results, search_results.names def complete(cmdline, point): - choices = Completer().complete(cmdline, point) + choices = Completer().complete(cmdline, point) or [] print(u" \n".join(choices)) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 97c741075..2d25df923 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -53,6 +53,9 @@ class MainSubcommandLoader(SubcommandLoader): HIGH_RISK_EMPLOYEE = DetectionLists.HIGH_RISK_EMPLOYEE LEGAL_HOLD = u"legal-hold" + def __init__(self): + super(MainSubcommandLoader, self).__init__(u"") + def load_commands(self): detection_lists_description = ( u"For adding and removing employees from the {} detection list." @@ -118,7 +121,7 @@ def _create_legal_hold_loader(self): def main(): - top = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) + top = Command(u"", u"", subcommand_loader=MainSubcommandLoader()) invoker = CommandInvoker(top) try: invoker.run(sys.argv[1:]) diff --git a/src/code42cli/tree_nodes.py b/src/code42cli/tree_nodes.py new file mode 100644 index 000000000..fc30e3f90 --- /dev/null +++ b/src/code42cli/tree_nodes.py @@ -0,0 +1,104 @@ +class CLINode(object): + """Base class for identifying nodes in the command/argument hierarchy.""" + + @property + def names(self): + """Override""" + return [] + + +class ChoicesNode(CLINode): + """A node who `names` refer to choices the user can select for an argument.""" + + def __init__(self, options): + self._choices = options + + def __iter__(self): + return iter(self._choices) + + def __getitem__(self, item): + return self._choices[item] + + def get(self, item): + return self._choices.get(item) + + @property + def names(self): + return self._choices + + +class ArgNode(CLINode): + """A node whose `names` are a list of flagged arguments the user can select from.""" + + def __init__(self, args): + self.args = args + + @property + def names(self): + try: + arg_names = [ + n + for names in [self.args[key].settings[u"options_list"] for key in self.args] + for n in names + if n.startswith("--") + ] + return arg_names + except: + return self.args + + def __getitem__(self, item): + """Access sub loaders to navigate the argument/options tree, connected to a leaf command.""" + if item in self.args: + return ArgNode(self.args) + + for key in self.args: + arg = self.args[key] + if item not in arg.settings[u"options_list"]: + continue + choices = arg.settings[u"choices"] + if choices: + return ChoicesNode(choices) + return ArgNode(self.args) + + def __iter__(self): + return iter(self.names) + + +class SubcommandNode(CLINode): + """Gets command information ahead of command-execution.""" + + def __init__(self, root_command_name, commands): + self.root = root_command_name + self.commands = commands + + def __getitem__(self, item): + try: + return self._subtrees[item] + except KeyError: + return self._get_args(item) + + def _get_args(self, item): + cmd = self._get_command_by_name(item) + if cmd: + args = cmd.get_arg_configs() + return ArgNode(args) + + def _get_command_by_name(self, name): + for cmd in self.commands: + if cmd.name == name: + return cmd + + @property + def names(self): + """The names of all the subcommands in this subcommand loader's root command.""" + return [cmd.name for cmd in self.commands] + + @property + def _subtrees(self): + """Maps subcommand names to their respective subcommand nodes.""" + results = {} + for cmd in self.commands: + if cmd.subcommand_loader: + commands = cmd.subcommand_loader.load_commands() + results[cmd.name] = SubcommandNode(cmd.name, commands) + return results diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 3c1f97d37..084242386 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,10 +1,11 @@ from __future__ import print_function import sys import shutil - +import os +import glob +from os import path from collections import OrderedDict from functools import wraps -from os import makedirs, path from signal import signal, getsignal, SIGINT from code42cli.compat import open, str @@ -14,12 +15,7 @@ def get_input(prompt): - """Uses correct input function based on Python version.""" - # pylint: disable=undefined-variable - if sys.version_info >= (3, 0): - return input(prompt) - else: - return raw_input(prompt) + return input(prompt) def does_user_agree(prompt): @@ -36,7 +32,7 @@ def get_user_project_path(subdir=u""): hidden_package_name = u".{0}".format(package_name) user_project_path = path.join(home, hidden_package_name, subdir) if not path.exists(user_project_path): - makedirs(user_project_path) + os.makedirs(user_project_path) return user_project_path @@ -177,6 +173,24 @@ def inner(*args, **kwargs): return inner +def get_files_in_path(input_path): + try: + if not input_path: + return os.listdir(os.getcwd()) + + if "~" in input_path: + replace = os.path.expanduser("~") + input_path = input_path.replace("~", replace) + + if os.path.isdir(input_path) and input_path[-1] != os.sep: + input_path += os.sep + + files = glob.glob(input_path + "*") + return files + except Exception: + return [] + + def get_user_id(sdk, username): """Returns the user's UID (referred to by `user_id` in detection lists). Raises `UserDoesNotExistError` if the user doesn't exist in the Code42 server. diff --git a/tests/cmds/alerts/test_extraction.py b/tests/cmds/alerts/test_extraction.py index 91427dc59..eb316c8b5 100644 --- a/tests/cmds/alerts/test_extraction.py +++ b/tests/cmds/alerts/test_extraction.py @@ -1,7 +1,6 @@ import logging import pytest -from py42.sdk import SDKClient from py42.sdk.queries.alerts.filters import * import code42cli.cmds.alerts.extraction as extraction_module diff --git a/tests/conftest.py b/tests/conftest.py index d776aed64..bc8c58da5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ from code42cli.file_readers import CliFileReader from code42cli.config import ConfigAccessor from code42cli.profile import Code42Profile -from code42cli.commands import DictObject +from code42cli.commands import DictObject, Command, SubcommandLoader import code42cli.errors as error_tracker @@ -227,3 +227,13 @@ def get_rows_count(self): return len(rows) return MockDictReader(TEST_FILE_PATH) + + +subcommand1 = Command("sub1", "sub1 desc", "sub1 usage") +subcommand2 = Command("sub2", "sub2 desc", "sub2 usage") +subcommand3 = Command("sub3", "sub3 desc", "sub3 usage") + + +class DummySubcommandLoader(SubcommandLoader): + def load_commands(self): + return [subcommand1, subcommand2, subcommand3] diff --git a/tests/test_commands.py b/tests/test_commands.py index 4cd732c14..8ce2e6b57 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -14,17 +14,12 @@ func_single_positional_arg_with_sdk_and_profile, func_single_positional_arg, func_single_positional_arg_many_optional_args, + subcommand1, + subcommand2, + subcommand3, + DummySubcommandLoader, ) -subcommand1 = Command("sub1", "sub1 desc", "sub1 usage") -subcommand2 = Command("sub2", "sub2 desc", "sub2 usage") -subcommand3 = Command("sub3", "sub3 desc", "sub3 usage") - - -class DummySubcommandLoader(SubcommandLoader): - def load_commands(self): - return [subcommand1, subcommand2, subcommand3] - def arg_customizer(arg_collection): arg_collection.append("success", ArgConfig("--success")) @@ -276,7 +271,7 @@ def dummy_print_help(): assert command(help_func=dummy_print_help) == "success" -class TestCommandSubcommandLoader(object): +class TestSubcommandLoader(object): def test_names_when_no_subcommands_returns_nothing(self): subcommand_loader = SubcommandLoader("") assert not subcommand_loader.names @@ -290,14 +285,9 @@ def test_names_returns_expected_names(self): ] assert subcommand_loader.names == ["c1", "c2", "c3"] - def test_subtrees_returns_expected_substree(self): + def test_getitem_returns_expected_subtree(self): subcommand_loader = SubcommandLoader("") - subcommand_loader_sub = SubcommandLoader("sub") - subcommand_loader_sub.load_commands = lambda: [ - Command("c1", ""), - Command("c2", ""), - Command("c3", ""), - ] command = Command("c1", "", subcommand_loader=DummySubcommandLoader("")) subcommand_loader.load_commands = lambda: [command] - assert subcommand_loader.subtrees + assert subcommand_loader.names == ["c1"] + assert subcommand_loader["c1"].names == ["sub1", "sub2", "sub3"] diff --git a/tests/test_completer.py b/tests/test_completer.py index 9df9d4ad9..fad0b06ca 100644 --- a/tests/test_completer.py +++ b/tests/test_completer.py @@ -1,7 +1,14 @@ +import pytest + from code42cli.completer import Completer from code42cli.main import MainSubcommandLoader +@pytest.fixture +def files(mocker): + return mocker.patch("code42cli.completer.get_files_in_path") + + class TestCompleter(object): _completer = Completer() @@ -23,7 +30,7 @@ def test_complete_for_alert_and_rules(self): assert "alerts" in actual assert "alert-rules" in actual assert len(actual) == 2 - + def test_complete_for_departing_employee(self): actual = self._completer.complete("code42 de") assert "departing-employee" in actual @@ -38,7 +45,7 @@ def test_profile_create(self): actual = self._completer.complete("code42 profile cre") assert "create" in actual assert len(actual) == 1 - + def test_complete_for_high_risk_employee_bulk(self): actual = self._completer.complete("code42 high-risk-employee bu") assert "bulk" in actual @@ -95,3 +102,71 @@ def test_complete_when_error_occurs_returns_empty_list(self, mocker): completer = Completer(loader) actual = completer.complete("code42 dep") assert not actual + + def test_complete_when_completing_arg_works(self): + actual = self._completer.complete("code42 security-data print --incre") + assert "--incremental" in actual + + def test_complete_does_not_complete_positional_args(self): + actual = self._completer.complete("code42 profile use nam") + assert "name" not in actual + + def test_complete_completes_choices(self): + actual = self._completer.complete("code42 security-data send-to 127.0.0.1 -p U") + assert "UDP" in actual + + def test_complete_when_names_contains_filename_and_current_is_positional_completes_with_local_filenames( + self, files + ): + files.return_value = ["foo.txt", "bar.csv"] + actual = self._completer.complete("code42 security-data write-to ") + assert "foo.txt" in actual + assert "bar.csv" in actual + + def test_complete_when_names_contains_file_name_and_current_is_positional_completes_with_local_filenames( + self, files + ): + files.return_value = ["foo.txt", "bar.csv"] + actual = self._completer.complete("code42 alert-rules bulk add ") + assert "foo.txt" in actual + assert "bar.csv" in actual + + def test_complete_completes_local_files(self, files): + files.return_value = ["foo.txt", "bar.csv"] + actual = self._completer.complete("code42 security-data write-to foo.t") + assert "foo.txt" in actual + assert len(actual) == 1 + + def test_complete_when_current_is_prefix_to_local_file_but_is_not_arg_does_not_complete_with_local_file( + self, files + ): + files.return_value = ["bulk.txt"] + actual = self._completer.complete("code42 departing-employee bu") + assert "bulk.txt" not in actual + assert "bulk" in actual + + def test_complete_when_nothing_matches_top_level_command_returns_nothing(self): + actual = self._completer.complete("code42 XX") + assert not actual + + def test_complete_when_nothing_matches_second_level_commands_returns_nothing(self): + actual = self._completer.complete("code42 security-data prX") + assert not actual + + def test_complete_when_nothing_matches_flagged_arg_returns_nothing(self): + actual = self._completer.complete("code42 security-data print --begX") + assert not actual + + def test_complete_when_nothing_matches_choice_returns_nothing(self): + actual = self._completer.complete("code42 security-data send-to -p XX") + assert not actual + + def test_complete_when_nothing_matches_files_return_nothing(self, files): + files.return_value = ["bulk.txt"] + actual = self._completer.complete("code42 departing-employee buX") + assert not actual + + def test_completer_ignore_shorthand_flagged_args(self): + actual = self._completer.complete("code42 alerts write-to -") + assert "-i" not in actual + assert "--incremental" in actual diff --git a/tests/test_invoker.py b/tests/test_invoker.py index 318f0266d..09cfcbd7c 100644 --- a/tests/test_invoker.py +++ b/tests/test_invoker.py @@ -153,7 +153,7 @@ def test_run_when_cli_error_occurs_logs_request(self, mocker, mock_parser, caplo assert "a code42cli error" in caplog.text def test_run_incorrect_command_suggests_proper_sub_commands(self, caplog): - command = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) + command = Command(u"", u"", subcommand_loader=MainSubcommandLoader()) cmd_invoker = CommandInvoker(command) with pytest.raises(SystemExit): cmd_invoker.run([u"profile", u"crate"]) @@ -162,7 +162,7 @@ def test_run_incorrect_command_suggests_proper_sub_commands(self, caplog): assert u"create" in caplog.text def test_run_incorrect_command_suggests_proper_main_commands(self, caplog): - command = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) + command = Command(u"", u"", subcommand_loader=MainSubcommandLoader()) cmd_invoker = CommandInvoker(command) with pytest.raises(SystemExit): cmd_invoker.run([u"prfile", u"crate"]) @@ -171,7 +171,7 @@ def test_run_incorrect_command_suggests_proper_main_commands(self, caplog): assert u"profile" in caplog.text def test_run_incorrect_command_suggests_proper_argument_name(self, caplog): - command = Command(u"", u"", subcommand_loader=MainSubcommandLoader(u"")) + command = Command(u"", u"", subcommand_loader=MainSubcommandLoader()) cmd_invoker = CommandInvoker(command) with pytest.raises(SystemExit): cmd_invoker.run([u"security-data", u"write-to", u"abc", u"--filename"]) diff --git a/tests/test_main.py b/tests/test_main.py index 990dfdae4..e0722d5e2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,33 @@ +from code42cli.main import main, MainSubcommandLoader + + +class TestMainSubcommandLoader(object): + def test_getitem_returns_top_level_subcommand_names(self): + loader = MainSubcommandLoader() + assert "alerts" in loader.names + assert "alert-rules" in loader.names + assert "departing-employee" in loader.names + + def test_getitem_when_at_alert_level_returns_alerts_subcommand_names(self): + loader = MainSubcommandLoader() + subloader = loader[loader.ALERTS].names + assert "print" in subloader + assert "write-to" in subloader + assert "clear-checkpoint" in subloader + + def test_getitem_returns_flagged_arg_names_when_is_leaf_command(self): + loader = MainSubcommandLoader() + args = loader[loader.ALERTS][u"print"] + assert "--incremental" in args + assert "--actor" in args + + def test_getitem_returns_choices_when_is_choice_based_arg(self): + loader = MainSubcommandLoader() + args = loader[loader.SECURITY_DATA][u"send-to"][u"127.0.0.1"]["-p"] + assert "UDP" in args + + # run the help commands on some stuff to prove stuff loads -from code42cli.main import main def _execute_test(capsys, assert_command, assert_value=False): diff --git a/tests/test_parser.py b/tests/test_parser.py index b7e2fbe31..7c105b1cd 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -17,7 +17,7 @@ def dummy_method_optional_args(one=None, two=None): return "success" -class TestSubcommandLoader(SubcommandLoader): +class MockSubcommandLoader(SubcommandLoader): def load_commands(self): return [Command("testsub1", "the subdesc1"), Command("testsub2", "the subdesc2")] @@ -105,7 +105,7 @@ def test_prepare_command_when_extra_args_throws(self): def test_prepare_cli_help_outputs_group_info(self, capsys): cmd = Command( - "runnable", "the desc", "the usage", subcommand_loader=TestSubcommandLoader("runnable") + "runnable", "the desc", "the usage", subcommand_loader=MockSubcommandLoader("runnable") ) parser = CommandParser() parser.prepare_cli_help(cmd) diff --git a/tests/test_tree_nodes.py b/tests/test_tree_nodes.py new file mode 100644 index 000000000..ab6e0750a --- /dev/null +++ b/tests/test_tree_nodes.py @@ -0,0 +1,63 @@ +from code42cli.tree_nodes import SubcommandNode, ArgNode +from code42cli.commands import Command +from code42cli.args import ArgConfig + +from .conftest import DummySubcommandLoader, func_single_positional_arg_many_optional_args + + +class TestSubcommandNode(object): + def test_names_returns_names_of_commands(self): + node = SubcommandNode("code42", [Command("foo", ""), Command("bar", "")]) + assert len(node.names) == 2 + assert "foo" in node.names + assert "bar" in node.names + + def test_getitem_when_item_is_subcommand_returns_its_node_with_expected_names(self): + loader = DummySubcommandLoader("test") + command = Command("test", "", subcommand_loader=loader) + node = SubcommandNode("code42", [Command("foo", ""), command]) + actual = node["test"].names + # values found in TestSubcommandLoader + assert "sub1" in actual + assert "sub2" in actual + assert "sub3" in actual + + def test_getitem_when_item_is_arg_node_returns_flagged_based_args(self): + command = Command("test", "", handler=func_single_positional_arg_many_optional_args) + node = SubcommandNode("code42", [Command("foo", ""), command]) + actual = node["test"].names + # values found in func_single_positional_arg_many_optional_args + assert "--two" in actual + assert "--three" in actual + assert "--four" in actual + + def test_getitem_when_item_is_arg_with_choices_returns_node_with_choices_for_names(self): + choices = ["something", "another"] + arg_config = ArgConfig("--two") + arg_config.set_choices(choices) + + def _customize_arg(argument_collection): + argument_collection.arg_configs["two"] = arg_config + + command = Command( + "test", + "", + handler=func_single_positional_arg_many_optional_args, + arg_customizer=_customize_arg, + ) + node = SubcommandNode("code42", [Command("foo", ""), command]) + test = node["test"] + actual = test["--two"].names + assert choices == actual + + +class TestArgNode(object): + def test_getitem_when_an_arg_has_choices_returns_choices_node(self): + arg1 = ArgConfig("-t") + arg2 = ArgConfig("-p") + choices = ["choice1, choice2, choice3"] + arg2.set_choices(choices) + + node = ArgNode({"arg1": arg1, "arg2": arg2}) + actual = node["-p"].names + assert choices == actual From f153ca3020ca2d183b60ae71cb248c2ca3d5b5bc Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 11 Jun 2020 13:58:40 -0500 Subject: [PATCH 079/349] Bugfix/lower default page size (#100) * lower page size to 500 to fix alert-rules * changelog and bump version * bump to 0.7.2 --- CHANGELOG.md | 6 ++++++ src/code42cli/__version__.py | 2 +- src/code42cli/sdk_client.py | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c176a8f..a02892291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 0.7.2 - 2020-06-11 + +### Fixed + +- Fixed bug that caused `alert-rules list` to error due to page size restrictions on backing service. + ## 0.7.1 - 2020-06-10 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index a5f830a2c..bc8c296f6 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.7.1" +__version__ = "0.7.2" diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index b1ccaf14f..1e659c049 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -1,8 +1,10 @@ import py42.sdk import py42.settings.debug as debug +import py42.settings from code42cli.logger import get_main_cli_logger +py42.settings.items_per_page = 500 def create_sdk(profile, is_debug_mode): if is_debug_mode: From c19222891287b8b77b3d178667a69fd76a93a7e1 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 12 Jun 2020 08:24:48 -0500 Subject: [PATCH 080/349] Add shabang (#101) --- bin/code42cli_completer | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/code42cli_completer b/bin/code42cli_completer index d893c11f3..3b2873763 100755 --- a/bin/code42cli_completer +++ b/bin/code42cli_completer @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # file inspired from awscli https://github.com/aws/aws-cli/blob/develop/bin/aws_completer import os From d7e1354abe140231c306ea52f72f1492ff9618af Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 24 Jun 2020 08:39:41 +0530 Subject: [PATCH 081/349] Test/integrations (#91) * Added initial setup for integration tests * Integration-test expects a profile set with password * Change tox to execute only unit tests * Refactor- use context management for teardown * Refactor- added teardown decorator * Add more alert tests * Added documentation * Use pexpect for prompt * Fix response change in pexpect * Added assert statement to validate non-empty response * Receiving EOF should be considered success * Fix single line response * make cleanup task OS independent for integration test --- integration/__init__.py | 33 +++++++++++++++++++ integration/test_alerts.py | 67 ++++++++++++++++++++++++++++++++++++++ integration/util.py | 29 +++++++++++++++++ run_integration.py | 11 +++++++ setup.py | 1 + tox.ini | 2 +- 6 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 integration/__init__.py create mode 100644 integration/test_alerts.py create mode 100644 integration/util.py create mode 100644 run_integration.py diff --git a/integration/__init__.py b/integration/__init__.py new file mode 100644 index 000000000..01c378121 --- /dev/null +++ b/integration/__init__.py @@ -0,0 +1,33 @@ +import os +import pexpect + + +LINE_FEED = b'\r\n' +PASSWORD_PROMPT = b'Password: ' +ENCODING_TYPE = 'utf-8' + + +def encode_response(line, encoding_type=ENCODING_TYPE): + return line.decode(encoding_type) + + +def run_command(command): + + process = pexpect.spawn(command) + response = [] + try: + expected = process.expect([PASSWORD_PROMPT, pexpect.EOF]) + if expected == 0: + process.sendline(os.environ["C42_PW"]) + process.expect(LINE_FEED) + output = process.readlines() + response = [encode_response(line) for line in output] + else: + output = process.before + response = encode_response(output).splitlines() + except pexpect.TIMEOUT: + return 1, response + return 0, response + + +__all__ = [run_command] diff --git a/integration/test_alerts.py b/integration/test_alerts.py new file mode 100644 index 000000000..892f2de83 --- /dev/null +++ b/integration/test_alerts.py @@ -0,0 +1,67 @@ +import pytest +import json + +from integration import run_command +from integration.util import cleanup_after_validation + +ALERT_COMMAND = "code42 alerts print -b 2020-05-18 -e 2020-05-20" + + +def _parse_response(response): + return [json.loads(line) for line in response if len(line)] + + +def _validate_field_value(field, value, response): + parsed_response = _parse_response(response) + assert len(parsed_response) > 0 + for record in parsed_response: + assert record[field] == value + + +@pytest.mark.parametrize( + "command, field, value", + [("{} --state OPEN".format(ALERT_COMMAND), "state", "OPEN"), + ("{} --state RESOLVED".format(ALERT_COMMAND), "state", "RESOLVED"), + ("{} --actor spatel@code42.com".format(ALERT_COMMAND), "actor", "spatel@code42.com"), + ("{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), "name", "File Upload Alert"), + ("{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND), "ruleId", + "962a6a1c-54f6-4477-90bd-a08cc74cbf71"), + ("{} --rule-type FedEndpointExfiltration".format(ALERT_COMMAND), "type", + "FED_ENDPOINT_EXFILTRATION"), + ("{} --description 'Alert on any file upload'".format(ALERT_COMMAND), "description", + "Alert on any file upload events"), + ] +) +def test_alert_prints_to_stdout_and_filters_result_by_given_value(command, field, value): + return_code, response = run_command(command) + assert return_code is 0 + _validate_field_value(field, value, response) + + +def _validate_begin_date(response): + parsed_response = _parse_response(response) + assert len(parsed_response) > 0 + for record in parsed_response: + assert record["createdAt"].startswith("2020-05-18") + + +@pytest.mark.parametrize("command, validate", [ + (ALERT_COMMAND, _validate_begin_date), +]) +def test_alert_prints_to_stdout_and_filters_result_between_given_date(command, validate): + return_code, response = run_command(command) + assert return_code is 0 + validate(response) + + +def _validate_severity(response): + record = json.loads(response) + assert record["severity"] == "MEDIUM" + + +@cleanup_after_validation("./integration/alerts") +def test_alert_writes_to_file_and_filters_result_by_severity(): + command = "code42 alerts write-to ./integration/alerts -b 2020-05-18 -e 2020-05-20 " \ + "--severity MEDIUM" + return_code, response = run_command(command) + return _validate_severity diff --git a/integration/util.py b/integration/util.py new file mode 100644 index 000000000..1f5b139d1 --- /dev/null +++ b/integration/util.py @@ -0,0 +1,29 @@ +import os + + +class cleanup(object): + def __init__(self, filename): + self.filename = filename + + def __enter__(self): + return open(self.filename, "r") + + def __exit__(self, exc_type, exc_val, exc_tb): + os.remove(self.filename) + + +def cleanup_after_validation(filename): + """Decorator to read response from file for `write-to` commands and cleanup the file after test + execution. + + The decorated function should return validation function that takes the content of the file + as input. e.g `test_alerts.py::test_alert_writes_to_file_and_filters_result_by_severity` + """ + def wrap(test_function): + def wrapper(): + validate = test_function() + with cleanup(filename) as f: + response = f.read() + validate(response) + return wrapper + return wrap diff --git a/run_integration.py b/run_integration.py new file mode 100644 index 000000000..26414a7cb --- /dev/null +++ b/run_integration.py @@ -0,0 +1,11 @@ +import sys +import os + +if __name__ == "__main__": + if sys.argv[1] and sys.argv[2]: + os.environ["C42_USER"] = sys.argv[1] + os.environ["C42_PW"] = sys.argv[2] + rc = os.system("pytest ./integration -v -rsxX -l --tb=short --strict") + sys.exit(rc) + else: + print("username and password were not supplied. Integration tests will be skipped.") diff --git a/setup.py b/setup.py index 64e4e16fd..bb59bf802 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ "keyring==18.0.1", "keyrings.alt==3.2.0", "py42>=1.2.0", + "pexpect>=4.8" ], license="MIT", include_package_data=True, diff --git a/tox.ini b/tox.ini index f3269b9f6..3e27d3bca 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ commands = # -l: show locals in tracebacks # --tb=short: short traceback print mode # --strict: marks not registered in configuration file raise errors - pytest --cov=code42cli --cov-append -v -rsxX -l --tb=short --strict --disable-pytest-warnings + pytest tests --cov=code42cli --cov-append -v -rsxX -l --tb=short --strict --disable-pytest-warnings depends = {py35,py36,py37,py38}: clean From 8f17a80fa670121180bff98542bd2d6bd1d91264 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Wed, 24 Jun 2020 08:47:19 -0500 Subject: [PATCH 082/349] check that all tasks have been processed instead of for empty queue (#105) --- CHANGELOG.md | 6 ++++++ src/code42cli/__version__.py | 2 +- src/code42cli/worker.py | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a02892291..36404bda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 0.7.3 - 2020-06-23 + +### Fixed + +- Fixed bug that caused the last few entries in csv files to sometimes not be processed when performing bulk processing actions. + ## 0.7.2 - 2020-06-11 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index bc8c296f6..4910b9ec3 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.7.2" +__version__ = "0.7.3" diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 0e6035508..f6ec9cf32 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -55,6 +55,7 @@ def __init__(self, thread_count, expected_total): self._queue = queue.Queue() self._thread_count = thread_count self._stats = WorkerStats(expected_total) + self._tasks = 0 self.__started = False self.__start_lock = Lock() @@ -72,6 +73,7 @@ def do_async(self, func, *args, **kwargs): self.__start() self.__started = True self._queue.put({u"func": func, u"args": args, u"kwargs": kwargs}) + self._tasks += 1 @property def stats(self): @@ -82,7 +84,7 @@ def stats(self): def wait(self): """Wait for the tasks in the queue to complete. This should usually be called before program termination.""" - while not self._queue.empty(): + while not self._stats.total_processed >= self._tasks: sleep(0.5) def _process_queue(self): From dedba050ec68bde286d3e1e560a8017d2588bf88 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Fri, 26 Jun 2020 07:34:31 +0530 Subject: [PATCH 083/349] Feature/saved search main (#103) * Add saved search list and show commands (#95) * Add saved search feature * Added extraction commands * Added tests * Refactor: Format and privatize methods * Added Changelog * Added missing test for send-to * Added usage note * Refactor extraction commands at securitydata level * Apply begin and end timestamp filter to saved search * Apply begin and end timestamp filter to saved search * Refactor/mutual exclusion saved search with advanced-query (#98) * Fix: Mutual exclusion of saved search with advanced query * Fix tests * Refactor- Merge changes from master * Privatize method and follow proper naming convention * refactor- Use constants instead of strings * Use public methods instead of private in tests * Add constant * Improved documentation * Made saved-search mutually exclusive with other args alike advance-query --- CHANGELOG.md | 7 + README.md | 4 + setup.py | 3 +- src/code42cli/cmds/alerts/extraction.py | 2 - src/code42cli/cmds/alerts/main.py | 17 +- src/code42cli/cmds/search_shared/args.py | 33 +++- .../cmds/search_shared/extraction.py | 13 -- src/code42cli/cmds/securitydata/extraction.py | 15 +- src/code42cli/cmds/securitydata/main.py | 51 +++++- .../cmds/securitydata/savedsearch/__init__.py | 0 .../cmds/securitydata/savedsearch/commands.py | 33 ++++ .../securitydata/savedsearch/savedsearch.py | 15 ++ src/code42cli/parser.py | 15 ++ tests/cmds/alerts/test_extraction.py | 70 -------- tests/cmds/alerts/test_main.py | 67 ++++++++ .../search_shared/test_advanced_query_args.py | 30 ++-- .../cmds/securitydata/savedsearch/__init__.py | 0 .../securitydata/savedsearch/test_commands.py | 18 +++ .../savedsearch/test_savedsearch.py | 15 ++ tests/cmds/securitydata/test_extraction.py | 92 +++-------- tests/cmds/securitydata/test_main.py | 151 +++++++++++++++++- tests/conftest.py | 1 + tests/test_parser.py | 58 ++++++- 23 files changed, 518 insertions(+), 192 deletions(-) create mode 100644 src/code42cli/cmds/securitydata/savedsearch/__init__.py create mode 100644 src/code42cli/cmds/securitydata/savedsearch/commands.py create mode 100644 src/code42cli/cmds/securitydata/savedsearch/savedsearch.py create mode 100644 tests/cmds/securitydata/savedsearch/__init__.py create mode 100644 tests/cmds/securitydata/savedsearch/test_commands.py create mode 100644 tests/cmds/securitydata/savedsearch/test_savedsearch.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 36404bda1..d9db07d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,13 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- Extraction subcommands of `code42 security-data`, `print/write-to/send-to` accepts argument `--saved-search` to + return saved search results. + +- `code42 security-data saved-search` commands: + - `list` prints out existing saved searches' id and name + - `show` takes a search id + - `code42 high-risk-employee bulk` supports `add-risk-tags` and `remove-risk-tags`. - `code42 high-risk-employee bulk generate-template ` options `add-risk-tags` and `remove-risk-tags`. - `add-risk-tags` that takes a csv file with username and space separated risk tags. diff --git a/README.md b/README.md index 21aed21b2..8d7534c57 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ To see all your profiles, do: code42 profile list ``` +A separate profile would be needed in order to keep the incremental checkpoints separate for different queries. +i.e User needs to maintain separate profiles for file event queries and saved search queries as only one checkpoint +is supported per profile. + ## Security Data and Alerts Using the CLI, you can query for security events and alerts and send them to three possible destination types: diff --git a/setup.py b/setup.py index bb59bf802..23621da48 100644 --- a/setup.py +++ b/setup.py @@ -24,8 +24,6 @@ "c42eventextractor==0.3.2", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42>=1.2.0", - "pexpect>=4.8" ], license="MIT", include_package_data=True, @@ -40,6 +38,7 @@ "sphinx", "sphinx_rtd_theme", "tox==3.14.3", + "pexpect>=4.8", ] }, classifiers=[ diff --git a/src/code42cli/cmds/alerts/extraction.py b/src/code42cli/cmds/alerts/extraction.py index caac70d1c..8ec259646 100644 --- a/src/code42cli/cmds/alerts/extraction.py +++ b/src/code42cli/cmds/alerts/extraction.py @@ -16,7 +16,6 @@ from code42cli.cmds.search_shared.extraction import ( verify_begin_date_requirements, create_handlers, - exit_if_advanced_query_used_with_other_search_args, create_time_range_filter, ) from code42cli.logger import get_main_cli_logger @@ -40,7 +39,6 @@ def extract(sdk, profile, output_logger, args): handlers = create_handlers(sdk, AlertExtractor, output_logger, store) extractor = AlertExtractor(sdk, handlers) if args.advanced_query: - exit_if_advanced_query_used_with_other_search_args(args, enums.AlertFilterArguments()) extractor.extract_advanced(args.advanced_query) else: verify_begin_date_requirements(args, store) diff --git a/src/code42cli/cmds/alerts/main.py b/src/code42cli/cmds/alerts/main.py index 690677b0e..5bb23b685 100644 --- a/src/code42cli/cmds/alerts/main.py +++ b/src/code42cli/cmds/alerts/main.py @@ -1,5 +1,6 @@ from code42cli.args import ArgConfig from code42cli.commands import Command, SubcommandLoader +from code42cli.parser import exit_if_mutually_exclusive_args_used_together from code42cli.cmds.alerts.extraction import extract from code42cli.cmds.search_shared import args, logger_factory from code42cli.cmds.search_shared.enums import ( @@ -10,6 +11,9 @@ RuleType, ) from code42cli.cmds.search_shared.cursor_store import AlertCursorStore +from code42cli.cmds.search_shared.args import ( + create_incompatible_search_args, SEARCH_FOR_ALERTS +) class MainAlertsSubcommandLoader(SubcommandLoader): @@ -67,20 +71,31 @@ def clear_checkpoint(sdk, profile): AlertCursorStore(profile.name).replace_stored_cursor_timestamp(None) +def _validate_args(args): + if args.advanced_query: + incompatible_search_args_dict = create_incompatible_search_args(SEARCH_FOR_ALERTS) + incompatible_search_args_list = list(incompatible_search_args_dict.keys()) + invalid_args = incompatible_search_args_list + list(AlertFilterArguments()) + exit_if_mutually_exclusive_args_used_together(args, invalid_args) + + def print_out(sdk, profile, args): """Activates 'print' command. It gets alerts and prints them to stdout.""" + _validate_args(args) logger = logger_factory.get_logger_for_stdout(args.format) extract(sdk, profile, logger, args) def write_to(sdk, profile, args): """Activates 'write-to' command. It gets alerts and writes them to the given file.""" + _validate_args(args) logger = logger_factory.get_logger_for_file(args.output_file, args.format) extract(sdk, profile, logger, args) def send_to(sdk, profile, args): """Activates 'send-to' command. It getsalerts and logs them to the given server.""" + _validate_args(args) logger = logger_factory.get_logger_for_server(args.server, args.protocol, args.format) extract(sdk, profile, logger, args) @@ -191,5 +206,5 @@ def _load_search_args(arg_collection): help=u"Filter alerts by description. Does fuzzy search by default.", ), } - search_args = args.create_search_args(search_for=u"alerts", filter_args=filter_args) + search_args = args.create_search_args(search_for=SEARCH_FOR_ALERTS, filter_args=filter_args) arg_collection.extend(search_args) diff --git a/src/code42cli/cmds/search_shared/args.py b/src/code42cli/cmds/search_shared/args.py index f1a4db05c..2d97ab3e5 100644 --- a/src/code42cli/cmds/search_shared/args.py +++ b/src/code42cli/cmds/search_shared/args.py @@ -1,12 +1,16 @@ from code42cli.cmds.search_shared.enums import SearchArguments, OutputFormat, AlertOutputFormat from code42cli.args import ArgConfig +SEARCH_FOR_ALERTS = u"alerts" +SEARCH_FOR_FILE_EVENTS = u"file events" -def create_search_args(search_for, filter_args): - advanced_query_incompatible_args = create_advanced_query_incompatible_search_args(search_for) - filter_args.update(advanced_query_incompatible_args) - format_enum = AlertOutputFormat() if search_for == "alerts" else OutputFormat() +def create_search_args(search_for, filter_args): + incompatible_args = create_incompatible_search_args(search_for) + filter_args.update(incompatible_args) + if search_for == SEARCH_FOR_FILE_EVENTS: + filter_args.update(_saved_search_args()) + format_enum = AlertOutputFormat() if search_for == SEARCH_FOR_ALERTS else OutputFormat() advanced_query_compatible_args = { SearchArguments.ADVANCED_QUERY: ArgConfig( @@ -31,7 +35,16 @@ def create_search_args(search_for, filter_args): return filter_args -def create_advanced_query_incompatible_search_args(search_for=None): +def _saved_search_args(): + saved_search = ArgConfig( + u"--saved-search", + help=u"Limits events to those discoverable with the saved search " + u"filters for the saved search with the given ID.\n" + u"WARNING: Using saved search is incompatible with other query-building args.") + return {u"saved_search": saved_search} + + +def create_incompatible_search_args(search_for=None): """Returns a dict of args that are incompatible with the --advanced-query flag. Any new incompatible args should go here as this is function is also used for arg validation.""" args = { @@ -60,3 +73,13 @@ def create_advanced_query_incompatible_search_args(search_for=None): ), } return args + + +def get_advanced_query_incompatible_search_args(search_for): + incompatible_args = create_incompatible_search_args(search_for) + incompatible_args.update(_saved_search_args()) + return incompatible_args + + +def get_saved_search_incompatible_search_args(search_for): + return create_incompatible_search_args(search_for) diff --git a/src/code42cli/cmds/search_shared/extraction.py b/src/code42cli/cmds/search_shared/extraction.py index 898c9c131..425e5d759 100644 --- a/src/code42cli/cmds/search_shared/extraction.py +++ b/src/code42cli/cmds/search_shared/extraction.py @@ -8,7 +8,6 @@ from code42cli.logger import get_main_cli_logger from code42cli.cmds.alerts.util import get_alert_details from code42cli.util import warn_interrupt -from code42cli.cmds.search_shared.args import create_advanced_query_incompatible_search_args logger = get_main_cli_logger() @@ -74,18 +73,6 @@ def handle_response(response): return handlers -def exit_if_advanced_query_used_with_other_search_args(args, search_arg_enum): - incompatible_search_args_dict = create_advanced_query_incompatible_search_args() - incompatible_search_args_list = list(incompatible_search_args_dict.keys()) - invalid_args = incompatible_search_args_list + list(search_arg_enum) - for arg in invalid_args: - if args.__dict__[arg]: - logger.print_and_log_error( - u"You cannot use --advanced-query with additional search args." - ) - exit(1) - - def create_time_range_filter(filter_cls, begin_date=None, end_date=None): """Creates a filter using the given filter class (must be a subclass of :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) and date args. Returns diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index 99e13661e..2a9fdaf30 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -3,13 +3,11 @@ from code42cli.cmds.search_shared.enums import ( ExposureType as ExposureTypeOptions, - FileEventFilterArguments, ) from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore from code42cli.cmds.search_shared.extraction import ( verify_begin_date_requirements, create_handlers, - exit_if_advanced_query_used_with_other_search_args, create_time_range_filter, ) import code42cli.errors as errors @@ -18,7 +16,7 @@ logger = get_main_cli_logger() -def extract(sdk, profile, output_logger, args): +def extract(sdk, profile, output_logger, args, query=None): """Extracts file events using the given command-line arguments. Args: @@ -29,18 +27,21 @@ def extract(sdk, profile, output_logger, args): write-to: uses a logger that logs to a file. send-to: uses a logger that sends logs to a server. args: Command line args used to build up file event query filters. + query: FileEventQuery instance created from search-id of saved search. """ store = FileEventCursorStore(profile.name) if args.incremental else None handlers = create_handlers(sdk, FileEventExtractor, output_logger, store) extractor = FileEventExtractor(sdk, handlers) - if args.advanced_query: - exit_if_advanced_query_used_with_other_search_args(args, FileEventFilterArguments()) - extractor.extract_advanced(args.advanced_query) - else: + if not args.advanced_query and not args.saved_search: verify_begin_date_requirements(args, store) if args.type: _verify_exposure_types(args.type) + if args.advanced_query: + extractor.extract_advanced(args.advanced_query) + else: filters = _create_file_event_filters(args) + if args.saved_search: + filters.extend(query._filter_group_list) extractor.extract(*filters) if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: logger.print_info(u"No results found.") diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index 6ad38e4f3..efce3015c 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -1,4 +1,5 @@ from code42cli.args import ArgConfig +from code42cli.parser import exit_if_mutually_exclusive_args_used_together from code42cli.cmds.search_shared import logger_factory, args from code42cli.cmds.search_shared.enums import ( FileEventFilterArguments, @@ -8,6 +9,12 @@ from code42cli.cmds.securitydata.extraction import extract from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore from code42cli.commands import Command, SubcommandLoader +from code42cli.cmds.securitydata.savedsearch.commands import SavedSearchSubCommandLoader +from code42cli.cmds.search_shared.args import ( + SEARCH_FOR_FILE_EVENTS, + get_advanced_query_incompatible_search_args, + get_saved_search_incompatible_search_args, +) class SecurityDataSubcommandLoader(SubcommandLoader): @@ -15,6 +22,7 @@ class SecurityDataSubcommandLoader(SubcommandLoader): WRITE_TO = u"write-to" SEND_TO = u"send-to" CLEAR_CHECKPOINT = u"clear-checkpoint" + SAVED_SEARCH = u"saved-search" def load_commands(self): """Sets up the `security-data` subcommand with all of its subcommands.""" @@ -54,7 +62,14 @@ def load_commands(self): handler=clear_checkpoint, ) - return [print_func, write, send, clear] + saved_search = Command( + self.SAVED_SEARCH, + u"Manage saved searches.", + subcommand_loader=SavedSearchSubCommandLoader(self.SAVED_SEARCH) + + ) + + return [print_func, write, send, clear, saved_search] def clear_checkpoint(sdk, profile): @@ -65,22 +80,48 @@ def clear_checkpoint(sdk, profile): FileEventCursorStore(profile.name).replace_stored_cursor_timestamp(None) +def _get_incompatible_search_args(incompatible_search_args_dict): + incompatible_search_args_list = list(incompatible_search_args_dict.keys()) + return incompatible_search_args_list + list(FileEventFilterArguments()) + + +def _validate_args(args): + + if args.advanced_query: + incompatible_search_args_dict = get_advanced_query_incompatible_search_args(SEARCH_FOR_FILE_EVENTS) + incompatible_search_args = _get_incompatible_search_args(incompatible_search_args_dict) + exit_if_mutually_exclusive_args_used_together(args, incompatible_search_args) + if args.saved_search: + incompatible_search_args_dict = get_saved_search_incompatible_search_args(SEARCH_FOR_FILE_EVENTS) + incompatible_search_args = _get_incompatible_search_args(incompatible_search_args_dict) + exit_if_mutually_exclusive_args_used_together(args, incompatible_search_args, u"--saved-search") + + +def _extract(sdk, profile, logger, args): + query = sdk.securitydata.savedsearches.get_query(args.saved_search) \ + if args.saved_search else None + extract(sdk, profile, logger, args, query) + + def print_out(sdk, profile, args): """Activates 'print' command. It gets security events and prints them to stdout.""" + _validate_args(args) logger = logger_factory.get_logger_for_stdout(args.format) - extract(sdk, profile, logger, args) + _extract(sdk, profile, logger, args) def write_to(sdk, profile, args): """Activates 'write-to' command. It gets security events and writes them to the given file.""" + _validate_args(args) logger = logger_factory.get_logger_for_file(args.output_file, args.format) - extract(sdk, profile, logger, args) + _extract(sdk, profile, logger, args) def send_to(sdk, profile, args): """Activates 'send-to' command. It gets security events and logs them to the given server.""" + _validate_args(args) logger = logger_factory.get_logger_for_server(args.server, args.protocol, args.format) - extract(sdk, profile, logger, args) + _extract(sdk, profile, logger, args) def _load_write_to_args(arg_collection): @@ -166,5 +207,5 @@ def _load_search_args(arg_collection): help=u"Get all events including non-exposure events.", ), } - search_args = args.create_search_args(search_for=u"file events", filter_args=filter_args) + search_args = args.create_search_args(search_for=SEARCH_FOR_FILE_EVENTS, filter_args=filter_args) arg_collection.extend(search_args) diff --git a/src/code42cli/cmds/securitydata/savedsearch/__init__.py b/src/code42cli/cmds/securitydata/savedsearch/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/code42cli/cmds/securitydata/savedsearch/commands.py b/src/code42cli/cmds/securitydata/savedsearch/commands.py new file mode 100644 index 000000000..bfdb368aa --- /dev/null +++ b/src/code42cli/cmds/securitydata/savedsearch/commands.py @@ -0,0 +1,33 @@ +from code42cli.commands import Command, SubcommandLoader +from code42cli.cmds.securitydata.savedsearch.savedsearch import show, show_detail + + +def _load_search_id_args(argument_collection): + search_id = argument_collection.arg_configs[u"search_id"] + search_id.set_help(u"The id of the saved search.") + + +class SavedSearchSubCommandLoader(SubcommandLoader): + LIST = u"list" + SHOW = u"show" + + def load_commands(self): + """Sets up security-data subcommand with all of its subcommands.""" + usage_prefix = u"code42 security-data saved-search" + + list_command = Command( + self.LIST, + u"List available saved searches.", + u"{} {}".format(usage_prefix, self.LIST), + handler=show, + ) + + show_command = Command( + self.SHOW, + "Get the details of a saved search.", + u"{} {} {}".format(usage_prefix, self.SHOW, u""), + handler=show_detail, + arg_customizer=_load_search_id_args, + ) + + return [list_command, show_command] diff --git a/src/code42cli/cmds/securitydata/savedsearch/savedsearch.py b/src/code42cli/cmds/securitydata/savedsearch/savedsearch.py new file mode 100644 index 000000000..08e028753 --- /dev/null +++ b/src/code42cli/cmds/securitydata/savedsearch/savedsearch.py @@ -0,0 +1,15 @@ +from pprint import pprint + +from code42cli.util import format_to_table, find_format_width + + +def show(sdk, profile): + response = sdk.securitydata.savedsearches.get() + header = {u"name": u"Name", u"id": u"Id"} + return format_to_table(*find_format_width(response[u"searches"], header)) + + +def show_detail(sdk, profile, search_id): + response = sdk.securitydata.savedsearches.get_by_id(search_id) + pprint(response["searches"]) + diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py index 122e9692d..1cafc5dc1 100644 --- a/src/code42cli/parser.py +++ b/src/code42cli/parser.py @@ -4,6 +4,9 @@ from py42.__version__ import __version__ as py42version from code42cli.__version__ import __version__ as cliversion +from code42cli.logger import get_main_cli_logger + + BANNER = u""" dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. @@ -120,3 +123,15 @@ def _build_group_command_descriptions(command): name_width = len(max([cmd.name for cmd in subs], key=len)) lines = [u" {} - {}".format(cmd.name.ljust(name_width), cmd.description) for cmd in subs] return u"\n".join(lines) + + +def exit_if_mutually_exclusive_args_used_together( + args, invalid_args, incompatible_with=u"--advanced-query" +): + for arg in invalid_args: + if args.__dict__[arg]: + logger = get_main_cli_logger() + logger.print_and_log_error( + u"You cannot use {0} with additional search args.".format(incompatible_with) + ) + exit(1) diff --git a/tests/cmds/alerts/test_extraction.py b/tests/cmds/alerts/test_extraction.py index eb316c8b5..fa9eb4426 100644 --- a/tests/cmds/alerts/test_extraction.py +++ b/tests/cmds/alerts/test_extraction.py @@ -53,76 +53,6 @@ def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( assert alert_extractor.extract.call_count == 0 -def test_extract_when_is_advanced_query_and_has_begin_date_exits( - sdk, profile, logger, alert_namespace -): - alert_namespace.advanced_query = "some complex json" - alert_namespace.begin = "begin date" - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -def test_extract_when_is_advanced_query_and_has_end_date_exits( - sdk, profile, logger, alert_namespace -): - alert_namespace.advanced_query = "some complex json" - alert_namespace.end = "end date" - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -@pytest.mark.parametrize( - "arg", - [ - "severity", - "actor", - "actor_contains", - "exclude_actor", - "exclude_actor_contains", - "rule_name", - "exclude_rule_name", - "rule_id", - "exclude_rule_id", - "rule_type", - "exclude_rule_type", - ], -) -def test_extract_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( - sdk, profile, logger, alert_namespace, arg -): - alert_namespace.advanced_query = "some complex json" - setattr(alert_namespace, arg, ["test_value"]) - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -@pytest.mark.parametrize("arg", ["state", "description"]) -def test_extract_when_is_advanced_query_and_other_incompatible_single_arg_argument_passed( - sdk, profile, logger, alert_namespace, arg -): - alert_namespace.advanced_query = "some complex json" - setattr(alert_namespace, arg, "test_value") - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -def test_extract_when_is_advanced_query_and_has_incremental_mode_exits( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.incremental = True - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( - sdk, profile, logger, alert_namespace -): - alert_namespace.advanced_query = "some complex json" - alert_namespace.is_incremental = False - extraction_module.extract(sdk, profile, logger, alert_namespace) - - def test_extract_when_is_not_advanced_query_uses_only_extract_method( sdk, profile, logger, alert_extractor, alert_namespace_with_begin ): diff --git a/tests/cmds/alerts/test_main.py b/tests/cmds/alerts/test_main.py index 039638458..81ae8d8c0 100644 --- a/tests/cmds/alerts/test_main.py +++ b/tests/cmds/alerts/test_main.py @@ -33,3 +33,70 @@ def test_send_to(sdk, profile, alert_namespace, mocker, mock_logger_factory, moc mock_logger_factory.get_logger_for_server.return_value = logger main.send_to(sdk, profile, alert_namespace) mock_extract.assert_called_with(sdk, profile, logger, alert_namespace) + + +def test_extract_when_is_advanced_query_and_has_begin_date_exits(sdk, profile, alert_namespace): + alert_namespace.advanced_query = "some complex json" + alert_namespace.begin = "begin date" + with pytest.raises(SystemExit): + main.send_to(sdk, profile, alert_namespace) + + +def test_extract_when_is_advanced_query_and_has_end_date_exits(sdk, profile, alert_namespace): + alert_namespace.advanced_query = "some complex json" + alert_namespace.end = "end date" + with pytest.raises(SystemExit): + main.print_out(sdk, profile, alert_namespace) + + +@pytest.mark.parametrize( + "arg", + [ + "severity", + "actor", + "actor_contains", + "exclude_actor", + "exclude_actor_contains", + "rule_name", + "exclude_rule_name", + "rule_id", + "exclude_rule_id", + "rule_type", + "exclude_rule_type", + ], +) +def test_extract_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( + sdk, profile, alert_namespace, arg +): + alert_namespace.advanced_query = "some complex json" + setattr(alert_namespace, arg, ["test_value"]) + with pytest.raises(SystemExit): + main.write_to(sdk, profile, alert_namespace) + + +@pytest.mark.parametrize("arg", ["state", "description"]) +def test_extract_when_is_advanced_query_and_other_incompatible_single_arg_argument_passed( + sdk, profile, alert_namespace, arg +): + alert_namespace.advanced_query = "some complex json" + setattr(alert_namespace, arg, "test_value") + with pytest.raises(SystemExit): + main.print_out(sdk, profile, alert_namespace) + + +def test_extract_when_is_advanced_query_and_has_incremental_mode_exits( + sdk, profile, alert_namespace): + alert_namespace.advanced_query = "some complex json" + alert_namespace.incremental = True + with pytest.raises(SystemExit): + main.print_out(sdk, profile, alert_namespace) + + +def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( + sdk, profile, alert_namespace, mock_extract, mocker, mock_logger_factory +): + logger = mocker.MagicMock() + mock_logger_factory.get_logger_for_server.return_value = logger + alert_namespace.advanced_query = "some complex json" + alert_namespace.is_incremental = False + main.print_out(sdk, profile, alert_namespace) diff --git a/tests/cmds/search_shared/test_advanced_query_args.py b/tests/cmds/search_shared/test_advanced_query_args.py index 685f487ab..35abb6f30 100644 --- a/tests/cmds/search_shared/test_advanced_query_args.py +++ b/tests/cmds/search_shared/test_advanced_query_args.py @@ -1,25 +1,25 @@ import pytest -from code42cli.cmds.search_shared.extraction import ( - exit_if_advanced_query_used_with_other_search_args, +from code42cli.parser import ( + exit_if_mutually_exclusive_args_used_together, ) -from code42cli.cmds.search_shared.enums import FileEventFilterArguments, AlertFilterArguments +from code42cli.cmds.search_shared.args import create_incompatible_search_args def test_exit_if_advanced_query_provided_incompatible_args( - mocker, file_event_namespace, alert_namespace + file_event_namespace, alert_namespace ): - mock = mocker.patch( - "code42cli.cmds.search_shared.extraction.create_advanced_query_incompatible_search_args" - ) - mock.return_value = { - "invalid_arg": None, - } - file_event_namespace.invalid_arg = "value" + file_event_namespace.advanced_query = "Not None" + file_event_namespace.begin = "value" with pytest.raises(SystemExit): - exit_if_advanced_query_used_with_other_search_args( - file_event_namespace, FileEventFilterArguments() + exit_if_mutually_exclusive_args_used_together( + file_event_namespace, + list(create_incompatible_search_args().keys()) ) - alert_namespace.invalid_arg = "value" + alert_namespace.advanced_query = "Not None" + alert_namespace.begin = "value" with pytest.raises(SystemExit): - exit_if_advanced_query_used_with_other_search_args(alert_namespace, AlertFilterArguments()) + exit_if_mutually_exclusive_args_used_together( + alert_namespace, + list(create_incompatible_search_args().keys()) + ) diff --git a/tests/cmds/securitydata/savedsearch/__init__.py b/tests/cmds/securitydata/savedsearch/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cmds/securitydata/savedsearch/test_commands.py b/tests/cmds/securitydata/savedsearch/test_commands.py new file mode 100644 index 000000000..3d01e2f55 --- /dev/null +++ b/tests/cmds/securitydata/savedsearch/test_commands.py @@ -0,0 +1,18 @@ +import pytest +from code42cli.cmds.securitydata.savedsearch.commands import SavedSearchSubCommandLoader + + +class TestSavedSearchSubCommandLoader(object): + + def test_load_commands_loads_expected_commands(self): + loader = SavedSearchSubCommandLoader("Test") + commands = loader.load_commands() + names = [command.name for command in commands] + assert set(names).issubset( + [ + SavedSearchSubCommandLoader.LIST, + SavedSearchSubCommandLoader.SHOW, + ] + ) + + diff --git a/tests/cmds/securitydata/savedsearch/test_savedsearch.py b/tests/cmds/securitydata/savedsearch/test_savedsearch.py new file mode 100644 index 000000000..ee486c732 --- /dev/null +++ b/tests/cmds/securitydata/savedsearch/test_savedsearch.py @@ -0,0 +1,15 @@ +import pytest +from code42cli.cmds.securitydata.savedsearch.savedsearch import ( + show, + show_detail +) + + +def test_show_calls_get_method(sdk_with_user, profile): + show(sdk_with_user, profile) + assert sdk_with_user.securitydata.savedsearches.get.call_count == 1 + + +def test_show_detail_calls_get_by_id_method(sdk_with_user, profile): + show_detail(sdk_with_user, profile, u"test-id") + sdk_with_user.securitydata.savedsearches.get_by_id.assert_called_once_with(u"test-id") diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index d780e86e4..b57ccbc7c 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -2,6 +2,7 @@ import logging from py42.sdk.queries.fileevents.filters import * +from py42.sdk.queries.fileevents.file_event_query import FileEventQuery import code42cli.cmds.securitydata.extraction as extraction_module import code42cli.errors as errors @@ -54,74 +55,6 @@ def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( assert file_event_extractor.extract.call_count == 0 -def test_extract_when_is_advanced_query_and_has_begin_date_exits( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.begin = "begin date" - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_is_advanced_query_and_has_end_date_exits( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.end = "end date" - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_is_advanced_query_and_has_exposure_types_exits( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.type = [ExposureTypeOptions.SHARED_TO_DOMAIN] - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -@pytest.mark.parametrize( - "arg", - [ - "c42_username", - "actor", - "md5", - "sha256", - "source", - "file_name", - "file_path", - "process_owner", - "tab_url", - ], -) -def test_extract_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( - sdk, profile, logger, file_event_namespace, arg -): - file_event_namespace.advanced_query = "some complex json" - setattr(file_event_namespace, arg, ["test_value"]) - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_is_advanced_query_and_has_incremental_mode_exits( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.incremental = True - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_is_advanced_query_and_has_include_non_exposure_exits( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.include_non_exposure = True - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - def test_extract_when_is_advanced_query_and_include_non_exposure_is_false_does_not_exit( sdk, profile, logger, file_event_namespace ): @@ -502,3 +435,26 @@ def sdk_side_effect(self, *args): with ErrorTrackerTestHelper(): extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) assert errors.ERRORED + + +def test_extract_saved_search_calls_extractor_extract_and_saved_search_execute( + sdk_with_user, profile, logger, file_event_extractor, file_event_namespace_with_begin +): + search_query = {"groupClause": "AND", + "groups": [{"filterClause": "AND", + "filters": [{"operator": "ON_OR_AFTER", "term": "eventTimestamp", + "value": "2020-05-01T00:00:00.000Z"}]}, + {"filterClause": "OR", + "filters": [{"operator": "IS", "term": "eventType", "value": "DELETED"}, + {"operator": "IS", "term": "eventType", "value": "EMAILED"}, + {"operator": "IS", "term": "eventType", "value": "MODIFIED"}, + {"operator": "IS", "term": "eventType", "value": "READ_BY_AP"}, + {"operator": "IS", "term": "eventType", "value": "CREATED"}] + }], + "pgNum": 1, "pgSize": 10000, "srtDir": "asc", "srtKey": "eventId" + } + query = FileEventQuery.from_dict(search_query) + extraction_module.extract( + sdk_with_user, profile, logger, file_event_namespace_with_begin, query + ) + assert file_event_extractor.extract.call_count == 1 diff --git a/tests/cmds/securitydata/test_main.py b/tests/cmds/securitydata/test_main.py index df5b5f46c..a10846756 100644 --- a/tests/cmds/securitydata/test_main.py +++ b/tests/cmds/securitydata/test_main.py @@ -2,6 +2,7 @@ import code42cli.cmds.securitydata.main as main from code42cli import PRODUCT_NAME +from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions @pytest.fixture @@ -18,18 +19,162 @@ def test_print_out(sdk, profile, file_event_namespace, mocker, mock_logger_facto logger = mocker.MagicMock() mock_logger_factory.get_logger_for_stdout.return_value = logger main.print_out(sdk, profile, file_event_namespace) - mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace) + mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace, None) def test_write_to(sdk, profile, file_event_namespace, mocker, mock_logger_factory, mock_extract): logger = mocker.MagicMock() mock_logger_factory.get_logger_for_file.return_value = logger main.write_to(sdk, profile, file_event_namespace) - mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace) + mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace, None) def test_send_to(sdk, profile, file_event_namespace, mocker, mock_logger_factory, mock_extract): logger = mocker.MagicMock() mock_logger_factory.get_logger_for_server.return_value = logger main.send_to(sdk, profile, file_event_namespace) - mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace) + mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace, None) + + +def test_validation_when_is_advanced_query_and_has_begin_date_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.begin = "begin date" + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) + + +def test_validation_when_is_advanced_query_and_has_end_date_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.end = "end date" + with pytest.raises(SystemExit): + main.write_to(sdk, profile, file_event_namespace) + + +def test_validation_when_is_advanced_query_and_has_exposure_types_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.type = [ExposureTypeOptions.SHARED_TO_DOMAIN] + with pytest.raises(SystemExit): + main.send_to(sdk, profile, file_event_namespace) + + +@pytest.mark.parametrize( + "arg", + [ + "c42_username", + "actor", + "md5", + "sha256", + "source", + "file_name", + "file_path", + "process_owner", + "tab_url", + ], +) +def test_validation_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( + sdk, profile, file_event_namespace, arg +): + file_event_namespace.advanced_query = "some complex json" + setattr(file_event_namespace, arg, ["test_value"]) + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) + + +def test_validation_when_is_advanced_query_and_has_incremental_mode_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.incremental = True + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) + + +def test_validation_when_is_advanced_query_and_has_include_non_exposure_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.include_non_exposure = True + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) + + +def test_validation_when_is_advanced_query_and_has_saved_search_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.saved_search = "abc" + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) + + +def test_validation_when_is_saved_search_and_has_begin_date_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.saved_search = "abc" + file_event_namespace.begin = "begin date" + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) + + +def test_validation_when_is_saved_search_and_has_end_date_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.saved_search = "abc" + file_event_namespace.end = "end date" + with pytest.raises(SystemExit): + main.write_to(sdk, profile, file_event_namespace) + + +def test_validation_when_is_saved_search_and_has_exposure_types_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.saved_search = "abc" + file_event_namespace.type = [ExposureTypeOptions.SHARED_TO_DOMAIN] + with pytest.raises(SystemExit): + main.send_to(sdk, profile, file_event_namespace) + + +def test_validation_when_is_saved_search_and_has_incremental_mode_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.saved_search = "abc" + file_event_namespace.incremental = True + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) + + +def test_validation_when_is_saved_search_and_has_include_non_exposure_exits( + sdk, profile, file_event_namespace +): + file_event_namespace.saved_search = "abc" + file_event_namespace.include_non_exposure = True + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) + +@pytest.mark.parametrize( + "arg", + [ + "c42_username", + "actor", + "md5", + "sha256", + "source", + "file_name", + "file_path", + "process_owner", + "tab_url", + ], +) +def test_validation_when_is_saved_search_and_other_incompatible_multi_narg_argument_passed( + sdk, profile, file_event_namespace, arg +): + file_event_namespace.saved_search = "abc" + setattr(file_event_namespace, arg, ["test_value"]) + with pytest.raises(SystemExit): + main.print_out(sdk, profile, file_event_namespace) diff --git a/tests/conftest.py b/tests/conftest.py index bc8c58da5..b104d300e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,7 @@ def file_event_namespace(): output_file=None, server=None, protocol=None, + saved_search=None, ) ) return args diff --git a/tests/test_parser.py b/tests/test_parser.py index 7c105b1cd..3a2fa28af 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,7 +1,13 @@ import pytest from code42cli.commands import Command, SubcommandLoader -from code42cli.parser import ArgumentParserError, CommandParser +from code42cli.parser import ( + ArgumentParserError, + CommandParser, + exit_if_mutually_exclusive_args_used_together, +) + +import code42cli.cmds.search_shared.enums as enums def dummy_method(): @@ -116,3 +122,53 @@ def test_prepare_cli_help_outputs_group_info(self, capsys): assert "testsub1" in captured.out assert "the subdesc2" in captured.out assert "testsub2" in captured.out + + +@pytest.mark.parametrize( + "arg", + [ + "c42_username", + "actor", + "md5", + "sha256", + "source", + "file_name", + "file_path", + "process_owner", + "tab_url", + ], +) +def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( + file_event_namespace, arg +): + file_event_namespace.advanced_query = "some complex json" + setattr(file_event_namespace, arg, ["test_value"]) + with pytest.raises(SystemExit): + exit_if_mutually_exclusive_args_used_together( + file_event_namespace, list(enums.FileEventFilterArguments()) + ) + + +def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_incremental_mode_does_not_exit_as_invalid_args_does_not_contain_incremental(alert_namespace): + alert_namespace.advanced_query = "some complex json" + alert_namespace.incremental = True + exit_if_mutually_exclusive_args_used_together( + alert_namespace, list(enums.AlertFilterArguments()) + ) + + +def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_include_non_exposure_exits(file_event_namespace): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.include_non_exposure = True + with pytest.raises(SystemExit): + exit_if_mutually_exclusive_args_used_together( + file_event_namespace, list(enums.FileEventFilterArguments()) + ) + + +def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_format_does_not_exit(file_event_namespace): + file_event_namespace.advanced_query = "some complex json" + file_event_namespace.format = "JSON" + exit_if_mutually_exclusive_args_used_together( + file_event_namespace, list(enums.FileEventFilterArguments()) + ) From ba832b3a5c045f32920d8d72ce782be6908e0fb1 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Mon, 29 Jun 2020 13:21:42 -0500 Subject: [PATCH 084/349] Feature/integ 1064 multi checkpoint (#106) --- CHANGELOG.md | 10 + README.md | 4 - docs/commands/alerts.md | 9 +- docs/commands/securitydata.md | 11 +- docs/userguides/siemexample.md | 2 +- src/code42cli/cmds/alerts/extraction.py | 4 +- src/code42cli/cmds/alerts/main.py | 12 +- src/code42cli/cmds/search_shared/args.py | 12 +- .../cmds/search_shared/cursor_store.py | 164 ++++++---------- src/code42cli/cmds/search_shared/enums.py | 2 +- .../cmds/search_shared/extraction.py | 14 +- src/code42cli/cmds/securitydata/extraction.py | 8 +- src/code42cli/cmds/securitydata/main.py | 34 ++-- src/code42cli/util.py | 11 +- tests/cmds/alerts/test_cursor_store.py | 87 --------- tests/cmds/alerts/test_extraction.py | 18 +- tests/cmds/alerts/test_main.py | 11 +- tests/cmds/conftest.py | 5 - tests/cmds/search_shared/test_cursor_store.py | 175 ++++++++++++++++++ tests/cmds/securitydata/test_cursor_store.py | 93 ---------- tests/cmds/securitydata/test_extraction.py | 60 +++--- tests/cmds/securitydata/test_main.py | 13 +- tests/conftest.py | 23 ++- tests/test_completer.py | 6 +- tests/test_main.py | 2 +- tests/test_parser.py | 14 +- 26 files changed, 400 insertions(+), 404 deletions(-) delete mode 100644 tests/cmds/alerts/test_cursor_store.py create mode 100644 tests/cmds/search_shared/test_cursor_store.py delete mode 100644 tests/cmds/securitydata/test_cursor_store.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d9db07d28..941fecabc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Changed + +- `-i` (`--incremental`) has been removed, use `-c` (`--use-checkpoint`) with a string name for the checkpoint instead. + +### Added + +- Profile can now save multiple alert and file event checkpoints. The name of the checkpoint to be used for a given query should be passed to `-c` (`--use-checkpoint`). + ## 0.7.3 - 2020-06-23 ### Fixed diff --git a/README.md b/README.md index 8d7534c57..21aed21b2 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,6 @@ To see all your profiles, do: code42 profile list ``` -A separate profile would be needed in order to keep the incremental checkpoints separate for different queries. -i.e User needs to maintain separate profiles for file event queries and saved search queries as only one checkpoint -is supported per profile. - ## Security Data and Alerts Using the CLI, you can query for security events and alerts and send them to three possible destination types: diff --git a/docs/commands/alerts.md b/docs/commands/alerts.md index 67a72841c..5d84a91f1 100644 --- a/docs/commands/alerts.md +++ b/docs/commands/alerts.md @@ -30,7 +30,7 @@ Search args are shared between `print`, `write-to`, and `send-to` commands. Available choices=['FedEndpointExfiltration', 'FedCloudSharePermissions', 'FedFileTypeMismatch']. * `--description`: Filter alerts by description. Does fuzzy search by default. * `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. -* `-i`, `--incremental` (optional): Only get file events that were not previously retrieved. +* `-c`, `--use-checkpoint` (optional): Get only file events that were not previously retrieved by writing the timestamp of the last event retrieved to a named checkpoint. ## print @@ -73,9 +73,12 @@ code42 alerts send-to ## clear-checkpoint -Remove the saved file event checkpoint from 'incremental' (-i) mode. +Arguments: +* `name`: The name to save this checkpoint as for later reuse. + +Remove the saved file event checkpoint from 'use-checkpoint' (-c) mode. Usage: ```bash -code42 alerts clear-checkpoint +code42 alerts clear-checkpoint ``` diff --git a/docs/commands/securitydata.md b/docs/commands/securitydata.md index fa2e1d89e..90737e2ea 100644 --- a/docs/commands/securitydata.md +++ b/docs/commands/securitydata.md @@ -6,7 +6,7 @@ Search args are shared between `print`, `write-to`, and `send-to` commands. * `--advanced-query` (optional): A raw JSON file events query. Useful for when the provided query parameters do not satisfy your requirements. WARNING: Using advanced queries is incompatible with other query-building args. -* `-b`, `--begin` (required except for non-first runs in incremental mode): The beginning of the date range in which to +* `-b`, `--begin` (required except for non-first runs in checkpoint mode): The beginning of the date range in which to look for file events, can be a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') or a short value representing days (30d), hours (24h) or minutes (15m) from current time. @@ -26,7 +26,7 @@ Search args are shared between `print`, `write-to`, and `send-to` commands. * `--tab-url` (optional): Limits events to be exposure events with one of these destination tab URLs. * `--include-non-exposure` (optional): Get all events including non-exposure events. * `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. -* `-i`, `--incremental` (optional): Only get file events that were not previously retrieved. +* `-c`, `--use-checkpoint` (optional): Get only file events that were not previously retrieved by writing the timestamp of the last event retrieved to a named checkpoint. ## print @@ -70,9 +70,12 @@ code42 security-data send-to ## clear-checkpoint -Remove the saved file event checkpoint from 'incremental' (-i) mode. +Arguments: +* `name`: The name to save this checkpoint as for later reuse. + +Remove the saved file event checkpoint from 'use-checkpoint' (-c) mode. Usage: ```bash -code42 security-data clear-checkpoint +code42 security-data clear-checkpoint ``` diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index 1c719daaf..2cffa9e97 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -32,7 +32,7 @@ code42 security-data send-to "https://syslog.example.com:514" -p TCP --profile p ``` Note that it is best practice to use a separate profile when executing a scheduled task. This way, it is harder to -accidentally mess up your stored checkpoints by running `--incremental` adhoc queries. +accidentally mess up your stored checkpoints by running `--use-checkpoint` adhoc queries. This query will send to the syslog server only the new security event data since the previous request. diff --git a/src/code42cli/cmds/alerts/extraction.py b/src/code42cli/cmds/alerts/extraction.py index 8ec259646..0b5c17eb9 100644 --- a/src/code42cli/cmds/alerts/extraction.py +++ b/src/code42cli/cmds/alerts/extraction.py @@ -35,8 +35,8 @@ def extract(sdk, profile, output_logger, args): send-to: uses a logger that sends logs to a server. args: Command line args used to build up alert query filters. """ - store = AlertCursorStore(profile.name) if args.incremental else None - handlers = create_handlers(sdk, AlertExtractor, output_logger, store) + store = AlertCursorStore(profile.name) if args.use_checkpoint else None + handlers = create_handlers(sdk, AlertExtractor, output_logger, store, args.use_checkpoint) extractor = AlertExtractor(sdk, handlers) if args.advanced_query: extractor.extract_advanced(args.advanced_query) diff --git a/src/code42cli/cmds/alerts/main.py b/src/code42cli/cmds/alerts/main.py index 5bb23b685..01856543d 100644 --- a/src/code42cli/cmds/alerts/main.py +++ b/src/code42cli/cmds/alerts/main.py @@ -11,9 +11,7 @@ RuleType, ) from code42cli.cmds.search_shared.cursor_store import AlertCursorStore -from code42cli.cmds.search_shared.args import ( - create_incompatible_search_args, SEARCH_FOR_ALERTS -) +from code42cli.cmds.search_shared.args import create_incompatible_search_args, SEARCH_FOR_ALERTS class MainAlertsSubcommandLoader(SubcommandLoader): @@ -55,7 +53,7 @@ def load_commands(self): clear = Command( self.CLEAR_CHECKPOINT, - u"Remove the saved alert checkpoint from 'incremental' (-i) mode.", + u"Remove the saved alert checkpoint from 'use-checkpoint' (-c) mode.", u"{} {}".format(usage_prefix, u"clear-checkpoint "), handler=clear_checkpoint, ) @@ -63,12 +61,12 @@ def load_commands(self): return [print_func, write, send, clear] -def clear_checkpoint(sdk, profile): +def clear_checkpoint(sdk, profile, cursor_name): """Removes the stored checkpoint that keeps track of the last alert retrieved for the given profile.. To use, run `code42 alerts clear-checkpoint`. - This affects `incremental` mode by causing it to behave like it has never been run before. + This affects `use-checkpoint` mode by resetting the checkpoint, causing it to behave like it has never been run before. """ - AlertCursorStore(profile.name).replace_stored_cursor_timestamp(None) + AlertCursorStore(profile.name).delete(cursor_name) def _validate_args(args): diff --git a/src/code42cli/cmds/search_shared/args.py b/src/code42cli/cmds/search_shared/args.py index 2d97ab3e5..b864d3146 100644 --- a/src/code42cli/cmds/search_shared/args.py +++ b/src/code42cli/cmds/search_shared/args.py @@ -39,8 +39,9 @@ def _saved_search_args(): saved_search = ArgConfig( u"--saved-search", help=u"Limits events to those discoverable with the saved search " - u"filters for the saved search with the given ID.\n" - u"WARNING: Using saved search is incompatible with other query-building args.") + u"filters for the saved search with the given ID.\n" + u"WARNING: Using saved search is incompatible with other query-building args.", + ) return {u"saved_search": saved_search} @@ -65,10 +66,9 @@ def create_incompatible_search_args(search_for=None): help=u"The end of the date range in which to look for {0}, " u"argument format options are the same as --begin.".format(search_for), ), - u"incremental": ArgConfig( - u"-i", - u"--incremental", - action=u"store_true", + u"use_checkpoint": ArgConfig( + u"-c", + u"--use-checkpoint", help=u"Only get {0} that were not previously retrieved.".format(search_for), ), } diff --git a/src/code42cli/cmds/search_shared/cursor_store.py b/src/code42cli/cmds/search_shared/cursor_store.py index bbdfecf28..33295d888 100644 --- a/src/code42cli/cmds/search_shared/cursor_store.py +++ b/src/code42cli/cmds/search_shared/cursor_store.py @@ -1,126 +1,80 @@ from __future__ import with_statement -import sqlite3 import os +from os import path +from code42cli.errors import Code42CLIError from code42cli.util import get_user_project_path +class Cursor(object): + def __init__(self, location): + self._location = location + self._name = path.basename(location) + + @property + def name(self): + return self._name + + @property + def value(self): + with open(self._location) as checkpoint: + return checkpoint.read() + + class BaseCursorStore(object): - _PRIMARY_KEY_COLUMN_NAME = u"cursor_id" - _timestamp_column_name = u"OVERRIDE" - _primary_key = u"OVERRIDE" - - def __init__(self, db_table_name, db_file_path=None): - self._table_name = db_table_name - if db_file_path is None: - db_path = get_user_project_path(u"db") - db_file = u"file_event_checkpoints.db" - db_file_path = os.path.join(db_path, db_file) - - self._connection = sqlite3.connect(db_file_path) - if self._is_empty(): - self._init_table() - - def _get(self, columns, primary_key): - query = u"SELECT {0} FROM {1} WHERE {2}=?" - query = query.format(columns, self._table_name, self._PRIMARY_KEY_COLUMN_NAME) - with self._connection as conn: - cursor = conn.cursor() - cursor.execute(query, (primary_key,)) - return cursor.fetchall() - - def _set(self, column_name, new_value, primary_key): - query = u"UPDATE {0} SET {1}=? WHERE {2}=?".format( - self._table_name, column_name, self._PRIMARY_KEY_COLUMN_NAME - ) - with self._connection as conn: - conn.execute(query, (new_value, primary_key)) - - def _delete(self, primary_key): - query = u"DELETE FROM {0} WHERE {1}=?".format( - self._table_name, self._PRIMARY_KEY_COLUMN_NAME - ) - with self._connection as conn: - conn.execute(query, (primary_key,)) - - def _row_exists(self, primary_key): - query = u"SELECT * FROM {0} WHERE {1}=?" - query = query.format(self._table_name, self._PRIMARY_KEY_COLUMN_NAME) - with self._connection as conn: - cursor = conn.cursor() - cursor.execute(query, (primary_key,)) - query_result = cursor.fetchone() - if not query_result: - return False - return True - - def _drop_table(self): - drop_query = u"DROP TABLE {0}".format(self._table_name) - with self._connection as conn: - conn.execute(drop_query) - - def _is_empty(self): - table_count_query = u""" - SELECT COUNT(name) - FROM sqlite_master - WHERE type='table' AND name=? - """ - with self._connection as conn: - cursor = conn.cursor() - cursor.execute(table_count_query, (self._table_name,)) - query_result = cursor.fetchone() - if query_result: - return int(query_result[0]) <= 0 - - def _init_table(self): - columns = u"{0}, {1}".format(self._PRIMARY_KEY_COLUMN_NAME, self._timestamp_column_name) - create_table_query = u"CREATE TABLE {0} ({1})".format(self._table_name, columns) - with self._connection as conn: - conn.execute(create_table_query) - - def _insert_new_row(self): - insert_query = u"INSERT INTO {0} VALUES(?, null)".format(self._table_name) - with self._connection as conn: - conn.execute(insert_query, (self._primary_key,)) - - def get_stored_cursor_timestamp(self): - """Gets the last stored date observed timestamp.""" - rows = self._get(self._timestamp_column_name, self._primary_key) - if rows and rows[0]: - return rows[0][0] + def __init__(self, dir_path): + self._dir_path = dir_path - def replace_stored_cursor_timestamp(self, new_date_observed_timestamp): + def get(self, cursor_name): + """Gets the last stored date observed timestamp.""" + try: + location = path.join(self._dir_path, cursor_name) + with open(location) as checkpoint: + return float(checkpoint.read()) + except FileNotFoundError: + return None + + def replace(self, cursor_name, new_timestamp): """Replaces the last stored date observed timestamp with the given one.""" - self._set( - column_name=self._timestamp_column_name, - new_value=new_date_observed_timestamp, - primary_key=self._primary_key, - ) + location = path.join(self._dir_path, cursor_name) + with open(location, "w") as checkpoint: + return checkpoint.write(str(new_timestamp)) + + def delete(self, cursor_name): + """Removes a single cursor from the store.""" + try: + location = path.join(self._dir_path, cursor_name) + os.remove(location) + except FileNotFoundError: + msg = "No checkpoint named {0} exists for this profile.".format(cursor_name) + raise Code42CLIError(msg) def clean(self): - """Removes profile cursor data from store.""" - self._delete(self._primary_key) + """Removes all cursors from this store.""" + cursors = self.get_all_cursors() + for cursor in cursors: + self.delete(cursor.name) + def get_all_cursors(self): + """Returns a list of all cursors stored in this directory (which istypically scoped to a profile).""" + dir_contents = os.listdir(self._dir_path) + return [Cursor(f) for f in dir_contents if self._is_file(f)] -class FileEventCursorStore(BaseCursorStore): - _timestamp_column_name = u"insertionTimestamp" + def _is_file(self, node_name): + return path.isfile(path.join(self._dir_path, node_name)) - def __init__(self, profile_name, db_file_path=None): - self._primary_key = profile_name - super(FileEventCursorStore, self).__init__(u"file_event_checkpoints", db_file_path) - if not self._row_exists(self._primary_key): - self._insert_new_row() +class FileEventCursorStore(BaseCursorStore): + def __init__(self, profile_name): + dir_path = get_user_project_path(u"file_event_checkpoints", profile_name) + super(FileEventCursorStore, self).__init__(dir_path) -class AlertCursorStore(BaseCursorStore): - _timestamp_column_name = u"createdAt" - def __init__(self, profile_name, db_file_path=None): - self._primary_key = profile_name - super(AlertCursorStore, self).__init__(u"alert_checkpoints", db_file_path) - if not self._row_exists(self._primary_key): - self._insert_new_row() +class AlertCursorStore(BaseCursorStore): + def __init__(self, profile_name): + dir_path = get_user_project_path(u"alert_checkpoints", profile_name) + super(AlertCursorStore, self).__init__(dir_path) def get_file_event_cursor_store(profile_name): diff --git a/src/code42cli/cmds/search_shared/enums.py b/src/code42cli/cmds/search_shared/enums.py index f09c64fbd..491954d9f 100644 --- a/src/code42cli/cmds/search_shared/enums.py +++ b/src/code42cli/cmds/search_shared/enums.py @@ -1,4 +1,4 @@ -IS_INCREMENTAL_KEY = u"incremental" +IS_CHECKPOINT_KEY = u"use_checkpoint" class OutputFormat(object): diff --git a/src/code42cli/cmds/search_shared/extraction.py b/src/code42cli/cmds/search_shared/extraction.py index 425e5d759..be2e60e70 100644 --- a/src/code42cli/cmds/search_shared/extraction.py +++ b/src/code42cli/cmds/search_shared/extraction.py @@ -13,14 +13,14 @@ def begin_date_is_required(args, cursor_store): - if not args.incremental: + if not args.use_checkpoint: return True - is_required = cursor_store and cursor_store.get_stored_cursor_timestamp() is None + is_required = cursor_store and cursor_store.get(args.use_checkpoint) is None - # Ignore begin date when in incremental mode, it is not required, and it was passed an argument. + # Ignore begin date when in use-checkpoint mode, it is not required, and it was passed an argument. if not is_required and args.begin: logger.print_and_log_info( - u"Ignoring --begin value as --incremental was passed and cursor checkpoint exists.\n" + u"Ignoring --begin value as --use-checkpoint was passed and cursor checkpoint exists.\n" ) args.begin = None return is_required @@ -33,7 +33,7 @@ def verify_begin_date_requirements(args, cursor_store): exit(1) -def create_handlers(sdk, extractor_class, output_logger, cursor_store): +def create_handlers(sdk, extractor_class, output_logger, cursor_store, checkpoint_name): extractor = extractor_class(sdk, ExtractionHandlers()) handlers = ExtractionHandlers() handlers.TOTAL_EVENTS = 0 @@ -49,8 +49,8 @@ def handle_error(exception): handlers.handle_error = handle_error if cursor_store: - handlers.record_cursor_position = cursor_store.replace_stored_cursor_timestamp - handlers.get_cursor_position = cursor_store.get_stored_cursor_timestamp + handlers.record_cursor_position = lambda value: cursor_store.replace(checkpoint_name, value) + handlers.get_cursor_position = lambda: cursor_store.get(checkpoint_name) @warn_interrupt( warning=u"Attempting to cancel cleanly to keep checkpoint data accurate. One moment..." diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py index 2a9fdaf30..a311fe5fe 100644 --- a/src/code42cli/cmds/securitydata/extraction.py +++ b/src/code42cli/cmds/securitydata/extraction.py @@ -1,9 +1,7 @@ from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.filters import * -from code42cli.cmds.search_shared.enums import ( - ExposureType as ExposureTypeOptions, -) +from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore from code42cli.cmds.search_shared.extraction import ( verify_begin_date_requirements, @@ -29,8 +27,8 @@ def extract(sdk, profile, output_logger, args, query=None): args: Command line args used to build up file event query filters. query: FileEventQuery instance created from search-id of saved search. """ - store = FileEventCursorStore(profile.name) if args.incremental else None - handlers = create_handlers(sdk, FileEventExtractor, output_logger, store) + store = FileEventCursorStore(profile.name) if args.use_checkpoint else None + handlers = create_handlers(sdk, FileEventExtractor, output_logger, store, args.use_checkpoint) extractor = FileEventExtractor(sdk, handlers) if not args.advanced_query and not args.saved_search: verify_begin_date_requirements(args, store) diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py index efce3015c..1619dadd9 100644 --- a/src/code42cli/cmds/securitydata/main.py +++ b/src/code42cli/cmds/securitydata/main.py @@ -57,27 +57,26 @@ def load_commands(self): clear = Command( self.CLEAR_CHECKPOINT, - u"Remove the saved file event checkpoint from 'incremental' (-i) mode.", - u"{} {}".format(usage_prefix, u"clear-checkpoint "), + u"Remove the saved file event checkpoint from 'use-checkpoint' (-c) mode.", + u"{} {}".format(usage_prefix, u"clear-checkpoint "), handler=clear_checkpoint, ) saved_search = Command( self.SAVED_SEARCH, u"Manage saved searches.", - subcommand_loader=SavedSearchSubCommandLoader(self.SAVED_SEARCH) - + subcommand_loader=SavedSearchSubCommandLoader(self.SAVED_SEARCH), ) return [print_func, write, send, clear, saved_search] -def clear_checkpoint(sdk, profile): +def clear_checkpoint(sdk, profile, cursor_name): """Removes the stored checkpoint that keeps track of the last file event retrieved for the given profile. To use, run `code42 security-data clear-checkpoint`. - This affects `incremental` mode by causing it to behave like it has never been run before. + This affects `use-checkpoint` mode by resetting the checkpoint, causing it to behave like it has never been run before. """ - FileEventCursorStore(profile.name).replace_stored_cursor_timestamp(None) + FileEventCursorStore(profile.name).delete(cursor_name) def _get_incompatible_search_args(incompatible_search_args_dict): @@ -88,18 +87,25 @@ def _get_incompatible_search_args(incompatible_search_args_dict): def _validate_args(args): if args.advanced_query: - incompatible_search_args_dict = get_advanced_query_incompatible_search_args(SEARCH_FOR_FILE_EVENTS) + incompatible_search_args_dict = get_advanced_query_incompatible_search_args( + SEARCH_FOR_FILE_EVENTS + ) incompatible_search_args = _get_incompatible_search_args(incompatible_search_args_dict) exit_if_mutually_exclusive_args_used_together(args, incompatible_search_args) if args.saved_search: - incompatible_search_args_dict = get_saved_search_incompatible_search_args(SEARCH_FOR_FILE_EVENTS) + incompatible_search_args_dict = get_saved_search_incompatible_search_args( + SEARCH_FOR_FILE_EVENTS + ) incompatible_search_args = _get_incompatible_search_args(incompatible_search_args_dict) - exit_if_mutually_exclusive_args_used_together(args, incompatible_search_args, u"--saved-search") + exit_if_mutually_exclusive_args_used_together( + args, incompatible_search_args, u"--saved-search" + ) def _extract(sdk, profile, logger, args): - query = sdk.securitydata.savedsearches.get_query(args.saved_search) \ - if args.saved_search else None + query = ( + sdk.securitydata.savedsearches.get_query(args.saved_search) if args.saved_search else None + ) extract(sdk, profile, logger, args, query) @@ -207,5 +213,7 @@ def _load_search_args(arg_collection): help=u"Get all events including non-exposure events.", ), } - search_args = args.create_search_args(search_for=SEARCH_FOR_FILE_EVENTS, filter_args=filter_args) + search_args = args.create_search_args( + search_for=SEARCH_FOR_FILE_EVENTS, filter_args=filter_args + ) arg_collection.extend(search_args) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 084242386..55551d0f9 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -25,15 +25,16 @@ def does_user_agree(prompt): return ans == u"y" -def get_user_project_path(subdir=u""): +def get_user_project_path(*subdirs): """The path on your user dir to /.code42cli/[subdir].""" package_name = __name__.split(u".")[0] home = path.expanduser(u"~") hidden_package_name = u".{0}".format(package_name) - user_project_path = path.join(home, hidden_package_name, subdir) - if not path.exists(user_project_path): - os.makedirs(user_project_path) - return user_project_path + user_project_path = path.join(home, hidden_package_name) + result_path = path.join(user_project_path, *subdirs) + if not path.exists(result_path): + os.makedirs(result_path) + return result_path def open_file(file_path, mode, action): diff --git a/tests/cmds/alerts/test_cursor_store.py b/tests/cmds/alerts/test_cursor_store.py deleted file mode 100644 index 277a80084..000000000 --- a/tests/cmds/alerts/test_cursor_store.py +++ /dev/null @@ -1,87 +0,0 @@ -from os import path - -from code42cli import PRODUCT_NAME -from code42cli.cmds.search_shared.cursor_store import BaseCursorStore, AlertCursorStore - - -class TestBaseCursorStore(object): - def test_init_cursor_store_when_not_given_db_file_path_uses_expected_default_checkpoints_path( - self, sqlite_connection - ): - home_dir = path.expanduser("~") - expected_path = path.join(home_dir, ".code42cli", "db") - db_table_name = "TEST" - expected_db_file_path = path.join(expected_path, "file_event_checkpoints.db") - BaseCursorStore(db_table_name) - sqlite_connection.assert_called_once_with(expected_db_file_path) - - def test_init_cursor_store_when_given_db_file_path_uses_given_path(self, sqlite_connection): - expected_db_file_path = "Hey, look, I'm a file path..." - BaseCursorStore("test", expected_db_file_path) - sqlite_connection.assert_called_once_with(expected_db_file_path) - - -class TestAlertCursorStore(object): - MOCK_TEST_DB_NAME = "test_path.db" - - def test_init_when_called_twice_with_different_profile_names_creates_two_rows( - self, mocker, sqlite_connection - ): - mock = mocker.patch( - "{}.cmds.search_shared.cursor_store.AlertCursorStore._row_exists".format(PRODUCT_NAME) - ) - mock.return_value = False - spy = mocker.spy(AlertCursorStore, "_insert_new_row") - AlertCursorStore("Profile A", self.MOCK_TEST_DB_NAME) - AlertCursorStore("Profile B", self.MOCK_TEST_DB_NAME) - assert spy.call_count == 2 - - def test_get_stored_cursor_timestamp_executes_expected_select_query(self, sqlite_connection): - store = AlertCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.get_stored_cursor_timestamp() - with store._connection as conn: - expected = "SELECT {0} FROM alert_checkpoints WHERE cursor_id=?".format(u"createdAt") - actual = conn.cursor().execute.call_args[0][0] - assert actual == expected - - def test_get_stored_cursor_timestamp_executes_query_with_expected_primary_key( - self, sqlite_connection - ): - store = AlertCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.get_stored_cursor_timestamp() - with store._connection as conn: - actual = conn.cursor().execute.call_args[0][1][0] - expected = store._primary_key - assert actual == expected - - def test_replace_stored_cursor_timestamp_executes_expected_update_query( - self, sqlite_connection - ): - store = AlertCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.replace_stored_cursor_timestamp(123) - with store._connection as conn: - expected = "UPDATE alert_checkpoints SET {0}=? WHERE cursor_id=?".format(u"createdAt") - actual = conn.execute.call_args[0][0] - assert actual == expected - - def test_replace_stored_cursor_timestamp_executes_query_with_expected_primary_key( - self, sqlite_connection - ): - store = AlertCursorStore("Profile", self.MOCK_TEST_DB_NAME) - new_cursor_timestamp = 123 - store.replace_stored_cursor_timestamp(new_cursor_timestamp) - with store._connection as conn: - actual = conn.execute.call_args[0][1][0] - assert actual == new_cursor_timestamp - - def test_clean_executes_query_with_expected_primary_key(self, sqlite_connection): - profile_name = "Profile" - store = AlertCursorStore(profile_name, self.MOCK_TEST_DB_NAME) - store.clean() - with store._connection as conn: - expected_query = "DELETE FROM {0} WHERE {1}=?".format( - store._table_name, store._PRIMARY_KEY_COLUMN_NAME - ) - actual_query, pk = conn.execute.call_args[0] - assert expected_query == actual_query - assert pk == (profile_name,) diff --git a/tests/cmds/alerts/test_extraction.py b/tests/cmds/alerts/test_extraction.py index fa9eb4426..c1fda7677 100644 --- a/tests/cmds/alerts/test_extraction.py +++ b/tests/cmds/alerts/test_extraction.py @@ -30,9 +30,7 @@ def alert_namespace_with_begin(alert_namespace): @pytest.fixture def alert_checkpoint(mocker): return mocker.patch( - "{}.cmds.search_shared.cursor_store.AlertCursorStore.get_stored_cursor_timestamp".format( - PRODUCT_NAME - ) + "{}.cmds.search_shared.cursor_store.AlertCursorStore.get".format(PRODUCT_NAME) ) @@ -163,7 +161,7 @@ def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( sdk, profile, logger, alert_namespace ): - alert_namespace.incremental = False + alert_namespace.use_checkpoint = None date = get_test_date_str(days_ago=91) + " 12:51:00" alert_namespace.begin = date with pytest.raises(DateArgumentError): @@ -179,21 +177,21 @@ def test_extract_when_end_date_is_before_begin_date_causes_exit( extraction_module.extract(sdk, profile, logger, alert_namespace) -def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( +def test_when_given_begin_date_past_90_days_and_uses_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( sdk, profile, logger, alert_namespace, alert_extractor, alert_checkpoint ): alert_namespace.begin = "2019-01-01" - alert_namespace.incremental = True + alert_namespace.use_checkpoint = "foo" alert_checkpoint.return_value = 22624624 extraction_module.extract(sdk, profile, logger, alert_namespace) assert not filter_term_is_in_call_args(alert_extractor, DateObserved._term) -def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( +def test_when_given_begin_date_and_not_use_checkpoint_mode_and_cursor_exists_uses_begin_date( sdk, profile, logger, alert_namespace, alert_extractor, alert_checkpoint ): alert_namespace.begin = get_test_date_str(days_ago=1) - alert_namespace.incremental = False + alert_namespace.use_checkpoint = None alert_checkpoint.return_value = 22624624 extraction_module.extract(sdk, profile, logger, alert_namespace) @@ -203,11 +201,11 @@ def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_b assert filter_term_is_in_call_args(alert_extractor, DateObserved._term) -def test_when_not_given_begin_date_and_is_incremental_but_no_stored_checkpoint_exists_causes_exit( +def test_when_not_given_begin_date_and_uses_checkpoint_but_no_stored_checkpoint_exists_causes_exit( sdk, profile, logger, alert_namespace, alert_checkpoint ): alert_namespace.begin = None - alert_namespace.is_incremental = True + alert_namespace.use_checkpoint = "foo" alert_checkpoint.return_value = None with pytest.raises(SystemExit): extraction_module.extract(sdk, profile, logger, alert_namespace) diff --git a/tests/cmds/alerts/test_main.py b/tests/cmds/alerts/test_main.py index 81ae8d8c0..b86b58f50 100644 --- a/tests/cmds/alerts/test_main.py +++ b/tests/cmds/alerts/test_main.py @@ -84,19 +84,20 @@ def test_extract_when_is_advanced_query_and_other_incompatible_single_arg_argume main.print_out(sdk, profile, alert_namespace) -def test_extract_when_is_advanced_query_and_has_incremental_mode_exits( - sdk, profile, alert_namespace): +def test_extract_when_is_advanced_query_and_use_checkpoint_mode_exits( + sdk, profile, alert_namespace +): alert_namespace.advanced_query = "some complex json" - alert_namespace.incremental = True + alert_namespace.use_checkpoint = "foo" with pytest.raises(SystemExit): main.print_out(sdk, profile, alert_namespace) -def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( +def test_extract_when_is_advanced_query_and_does_not_use_checkpoint_does_not_exit( sdk, profile, alert_namespace, mock_extract, mocker, mock_logger_factory ): logger = mocker.MagicMock() mock_logger_factory.get_logger_for_server.return_value = logger alert_namespace.advanced_query = "some complex json" - alert_namespace.is_incremental = False + alert_namespace.use_checkpoint = None main.print_out(sdk, profile, alert_namespace) diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 5fd56f611..90d37e6c4 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -40,11 +40,6 @@ def parse_date_from_filter_value(json, filter_index): return convert_str_to_date(date_str) -@pytest.fixture(autouse=True) -def sqlite_connection(mocker): - return mocker.patch("sqlite3.connect") - - ACCEPTABLE_ARGS = [ "-t", "SharedToDomain", diff --git a/tests/cmds/search_shared/test_cursor_store.py b/tests/cmds/search_shared/test_cursor_store.py new file mode 100644 index 000000000..7b351125f --- /dev/null +++ b/tests/cmds/search_shared/test_cursor_store.py @@ -0,0 +1,175 @@ +from os import path +from io import IOBase, StringIO + +import pytest + +from code42cli import PRODUCT_NAME +from code42cli.errors import Code42CLIError +from code42cli.cmds.search_shared.cursor_store import Cursor, AlertCursorStore, FileEventCursorStore + +PROFILE_NAME = "testprofile" +CURSOR_NAME = "testcursor" + +_NAMESPACE = "{}.cmds.search_shared.cursor_store".format(PRODUCT_NAME) + + +@pytest.fixture +def mock_open(mocker): + mock = mocker.patch("builtins.open", mocker.mock_open(read_data="123456789")) + return mock + + +@pytest.fixture +def mock_isfile(mocker): + mock = mocker.patch("{}.os.path.isfile".format(_NAMESPACE)) + mock.return_value = True + return mock + + +class TestCursor(object): + def test_name_returns_expected_name(self): + cursor = Cursor("bogus/path") + assert cursor.name == "path" + + def test_value_returns_expected_value(self, mock_open): + cursor = Cursor("bogus/path") + assert cursor.value == "123456789" + + def test_value_reads_expected_file(self, mock_open): + cursor = Cursor("bogus/path") + _ = cursor.value + mock_open.assert_called_once_with("bogus/path") + + +class TestAlertCursorStore(object): + def test_get_returns_expected_timestamp(self, mock_open): + store = AlertCursorStore(PROFILE_NAME) + checkpoint = store.get(CURSOR_NAME) + assert checkpoint == 123456789 + + def test_get_when_profile_does_not_exist_returns_none(self, mocker): + store = AlertCursorStore(PROFILE_NAME) + checkpoint = store.get(CURSOR_NAME) + mock_open = mocker.patch("{}.open".format(_NAMESPACE)) + mock_open.side_effect = FileNotFoundError + assert checkpoint == None + + def test_get_reads_expected_file(self, mock_open): + store = AlertCursorStore(PROFILE_NAME) + store.get(CURSOR_NAME) + user_path = path.expanduser("~/.code42cli") + expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, CURSOR_NAME) + mock_open.assert_called_once_with(expected_path) + + def test_replace_writes_to_expected_file(self, mock_open): + store = AlertCursorStore(PROFILE_NAME) + store.replace("checkpointname", 123) + user_path = path.expanduser("~/.code42cli") + expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname") + mock_open.assert_called_once_with(expected_path, "w") + + def test_replace_writes_expected_content(self, mock_open): + store = AlertCursorStore(PROFILE_NAME) + store.replace("checkpointname", 123) + user_path = path.expanduser("~/.code42cli") + expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname") + mock_open.return_value.write.assert_called_once_with("123") + + def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): + store = AlertCursorStore(PROFILE_NAME) + store.delete("deleteme") + user_path = path.expanduser("~/.code42cli") + expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "deleteme") + mock_remove.assert_called_once_with(expected_path) + + def test_delete_when_checkpoint_does_not_exist_raises_cli_error(self, mock_open, mock_remove): + store = AlertCursorStore(PROFILE_NAME) + mock_remove.side_effect = FileNotFoundError + with pytest.raises(Code42CLIError): + store.delete("deleteme") + + def test_clean_calls_remove_on_each_checkpoint( + self, mock_open, mock_remove, mock_listdir, mock_isfile + ): + mock_listdir.return_value = ["fileone", "filetwo", "filethree"] + store = AlertCursorStore(PROFILE_NAME) + store.clean() + assert mock_remove.call_count == 3 + + def test_get_all_cursors_returns_all_checkpoints(self, mock_open, mock_listdir, mock_isfile): + mock_listdir.return_value = ["fileone", "filetwo", "filethree"] + store = AlertCursorStore(PROFILE_NAME) + cursors = store.get_all_cursors() + assert len(cursors) == 3 + assert cursors[0].name == "fileone" + assert cursors[1].name == "filetwo" + assert cursors[2].name == "filethree" + + +class TestFileEventCursorStore(object): + def test_get_returns_expected_timestamp(self, mock_open): + store = FileEventCursorStore(PROFILE_NAME) + checkpoint = store.get(CURSOR_NAME) + assert checkpoint == 123456789 + + def test_get_reads_expected_file(self, mock_open): + store = FileEventCursorStore(PROFILE_NAME) + store.get(CURSOR_NAME) + user_path = path.expanduser("~/.code42cli") + expected_path = path.join(user_path, "file_event_checkpoints", PROFILE_NAME, CURSOR_NAME) + mock_open.assert_called_once_with(expected_path) + + def test_get_when_profile_does_not_exist_returns_none(self, mocker): + store = FileEventCursorStore(PROFILE_NAME) + checkpoint = store.get(CURSOR_NAME) + mock_open = mocker.patch("{}.open".format(_NAMESPACE)) + mock_open.side_effect = FileNotFoundError + assert checkpoint == None + + def test_replace_writes_to_expected_file(self, mock_open): + store = FileEventCursorStore(PROFILE_NAME) + store.replace("checkpointname", 123) + user_path = path.expanduser("~/.code42cli") + expected_path = path.join( + user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname" + ) + mock_open.assert_called_once_with(expected_path, "w") + + def test_replace_writes_expected_content(self, mock_open): + store = FileEventCursorStore(PROFILE_NAME) + store.replace("checkpointname", 123) + user_path = path.expanduser("~/.code42cli") + expected_path = path.join( + user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname" + ) + mock_open.return_value.write.assert_called_once_with("123") + + def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): + store = FileEventCursorStore(PROFILE_NAME) + store.delete("deleteme") + user_path = path.expanduser("~/.code42cli") + expected_path = path.join(user_path, "file_event_checkpoints", PROFILE_NAME, "deleteme") + mock_remove.assert_called_once_with(expected_path) + + def test_delete_when_checkpoint_does_not_exist_raises_cli_error(self, mock_open, mock_remove): + store = FileEventCursorStore(PROFILE_NAME) + mock_remove.side_effect = FileNotFoundError + with pytest.raises(Code42CLIError): + store.delete("deleteme") + + def test_clean_calls_remove_on_each_checkpoint( + self, mock_open, mock_remove, mock_listdir, mock_isfile + ): + mock_listdir.return_value = ["fileone", "filetwo", "filethree"] + store = FileEventCursorStore(PROFILE_NAME) + store.clean() + assert mock_remove.call_count == 3 + + def test_get_all_cursors_returns_all_checkpoints(self, mock_listdir, mock_isfile): + mock_listdir.return_value = ["fileone", "filetwo", "filethree"] + store = FileEventCursorStore(PROFILE_NAME) + cursors = store.get_all_cursors() + assert len(cursors) == 3 + assert cursors[0].name == "fileone" + assert cursors[1].name == "filetwo" + assert cursors[2].name == "filethree" diff --git a/tests/cmds/securitydata/test_cursor_store.py b/tests/cmds/securitydata/test_cursor_store.py deleted file mode 100644 index 602530a52..000000000 --- a/tests/cmds/securitydata/test_cursor_store.py +++ /dev/null @@ -1,93 +0,0 @@ -from os import path - -from code42cli import PRODUCT_NAME -from code42cli.cmds.search_shared.cursor_store import BaseCursorStore, FileEventCursorStore - - -class TestBaseCursorStore(object): - def test_init_cursor_store_when_not_given_db_file_path_uses_expected_default_checkpoints_path( - self, sqlite_connection - ): - home_dir = path.expanduser("~") - expected_path = path.join(home_dir, ".code42cli", "db") - db_table_name = "TEST" - expected_db_file_path = path.join(expected_path, "file_event_checkpoints.db") - BaseCursorStore(db_table_name) - sqlite_connection.assert_called_once_with(expected_db_file_path) - - def test_init_cursor_store_when_given_db_file_path_uses_given_path(self, sqlite_connection): - expected_db_file_path = "Hey, look, I'm a file path..." - BaseCursorStore("test", expected_db_file_path) - sqlite_connection.assert_called_once_with(expected_db_file_path) - - -class TestFileEventCursorStore(object): - MOCK_TEST_DB_NAME = "test_path.db" - - def test_init_when_called_twice_with_different_profile_names_creates_two_rows( - self, mocker, sqlite_connection - ): - mock = mocker.patch( - "{}.cmds.search_shared.cursor_store.FileEventCursorStore._row_exists".format( - PRODUCT_NAME - ) - ) - mock.return_value = False - spy = mocker.spy(FileEventCursorStore, "_insert_new_row") - FileEventCursorStore("Profile A", self.MOCK_TEST_DB_NAME) - FileEventCursorStore("Profile B", self.MOCK_TEST_DB_NAME) - assert spy.call_count == 2 - - def test_get_stored_cursor_timestamp_executes_expected_select_query(self, sqlite_connection): - store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.get_stored_cursor_timestamp() - with store._connection as conn: - expected = "SELECT {0} FROM file_event_checkpoints WHERE cursor_id=?".format( - u"insertionTimestamp" - ) - actual = conn.cursor().execute.call_args[0][0] - assert actual == expected - - def test_get_stored_cursor_timestamp_executes_query_with_expected_primary_key( - self, sqlite_connection - ): - store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.get_stored_cursor_timestamp() - with store._connection as conn: - actual = conn.cursor().execute.call_args[0][1][0] - expected = store._primary_key - assert actual == expected - - def test_replace_stored_cursor_timestamp_executes_expected_update_query( - self, sqlite_connection - ): - store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) - store.replace_stored_cursor_timestamp(123) - with store._connection as conn: - expected = "UPDATE file_event_checkpoints SET {0}=? WHERE cursor_id=?".format( - u"insertionTimestamp" - ) - actual = conn.execute.call_args[0][0] - assert actual == expected - - def test_replace_stored_cursor_timestamp_executes_query_with_expected_primary_key( - self, sqlite_connection - ): - store = FileEventCursorStore("Profile", self.MOCK_TEST_DB_NAME) - new_cursor_timestamp = 123 - store.replace_stored_cursor_timestamp(new_cursor_timestamp) - with store._connection as conn: - actual = conn.execute.call_args[0][1][0] - assert actual == new_cursor_timestamp - - def test_clean_executes_query_with_expected_primary_key(self, sqlite_connection): - profile_name = "Profile" - store = FileEventCursorStore(profile_name, self.MOCK_TEST_DB_NAME) - store.clean() - with store._connection as conn: - expected_query = "DELETE FROM {0} WHERE {1}=?".format( - store._table_name, store._PRIMARY_KEY_COLUMN_NAME - ) - actual_query, pk = conn.execute.call_args[0] - assert expected_query == actual_query - assert pk == (profile_name,) diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py index b57ccbc7c..dcce65be7 100644 --- a/tests/cmds/securitydata/test_extraction.py +++ b/tests/cmds/securitydata/test_extraction.py @@ -32,9 +32,7 @@ def file_event_namespace_with_begin(file_event_namespace): @pytest.fixture def file_event_checkpoint(mocker): return mocker.patch( - "{}.cmds.search_shared.cursor_store.FileEventCursorStore.get_stored_cursor_timestamp".format( - PRODUCT_NAME - ) + "{}.cmds.search_shared.cursor_store.FileEventCursorStore.get".format(PRODUCT_NAME) ) @@ -63,11 +61,11 @@ def test_extract_when_is_advanced_query_and_include_non_exposure_is_false_does_n extraction_module.extract(sdk, profile, logger, file_event_namespace) -def test_extract_when_is_advanced_query_and_has_incremental_mode_set_to_false_does_not_exit( +def test_extract_when_is_advanced_query_and_has_does_not_use_checkpoint_does_not_exit( sdk, profile, logger, file_event_namespace ): file_event_namespace.advanced_query = "some complex json" - file_event_namespace.is_incremental = False + file_event_namespace.use_checkpoint = None extraction_module.extract(sdk, profile, logger, file_event_namespace) @@ -179,7 +177,7 @@ def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( sdk, profile, logger, file_event_namespace ): - file_event_namespace.incremental = False + file_event_namespace.use_checkpoint = None date = get_test_date_str(days_ago=91) + " 12:51:00" file_event_namespace.begin = date with pytest.raises(DateArgumentError): @@ -195,11 +193,11 @@ def test_extract_when_end_date_is_before_begin_date_causes_exit( extraction_module.extract(sdk, profile, logger, file_event_namespace) -def test_when_given_begin_date_past_90_days_and_is_incremental_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( +def test_when_given_begin_date_past_90_days_and_uses_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( sdk, profile, logger, file_event_namespace, file_event_extractor, file_event_checkpoint ): file_event_namespace.begin = "2019-01-01" - file_event_namespace.incremental = True + file_event_namespace.use_checkpoint = "foo" file_event_checkpoint.return_value = 22624624 extraction_module.extract(sdk, profile, logger, file_event_namespace) assert not filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) @@ -209,7 +207,7 @@ def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_b sdk, profile, logger, file_event_namespace, file_event_extractor, file_event_checkpoint ): file_event_namespace.begin = get_test_date_str(days_ago=1) - file_event_namespace.incremental = False + file_event_namespace.use_checkpoint = None file_event_checkpoint.return_value = 22624624 extraction_module.extract(sdk, profile, logger, file_event_namespace) @@ -221,11 +219,11 @@ def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_b assert filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) -def test_when_not_given_begin_date_and_is_incremental_but_no_stored_checkpoint_exists_causes_exit( +def test_when_not_given_begin_date_and_uses_checkpoint_but_no_stored_checkpoint_exists_causes_exit( sdk, profile, logger, file_event_namespace, file_event_checkpoint ): file_event_namespace.begin = None - file_event_namespace.is_incremental = True + file_event_namespace.use_checkpoint = "foo" file_event_checkpoint.return_value = None with pytest.raises(SystemExit): extraction_module.extract(sdk, profile, logger, file_event_namespace) @@ -440,18 +438,34 @@ def sdk_side_effect(self, *args): def test_extract_saved_search_calls_extractor_extract_and_saved_search_execute( sdk_with_user, profile, logger, file_event_extractor, file_event_namespace_with_begin ): - search_query = {"groupClause": "AND", - "groups": [{"filterClause": "AND", - "filters": [{"operator": "ON_OR_AFTER", "term": "eventTimestamp", - "value": "2020-05-01T00:00:00.000Z"}]}, - {"filterClause": "OR", - "filters": [{"operator": "IS", "term": "eventType", "value": "DELETED"}, - {"operator": "IS", "term": "eventType", "value": "EMAILED"}, - {"operator": "IS", "term": "eventType", "value": "MODIFIED"}, - {"operator": "IS", "term": "eventType", "value": "READ_BY_AP"}, - {"operator": "IS", "term": "eventType", "value": "CREATED"}] - }], - "pgNum": 1, "pgSize": 10000, "srtDir": "asc", "srtKey": "eventId" + search_query = { + "groupClause": "AND", + "groups": [ + { + "filterClause": "AND", + "filters": [ + { + "operator": "ON_OR_AFTER", + "term": "eventTimestamp", + "value": "2020-05-01T00:00:00.000Z", + } + ], + }, + { + "filterClause": "OR", + "filters": [ + {"operator": "IS", "term": "eventType", "value": "DELETED"}, + {"operator": "IS", "term": "eventType", "value": "EMAILED"}, + {"operator": "IS", "term": "eventType", "value": "MODIFIED"}, + {"operator": "IS", "term": "eventType", "value": "READ_BY_AP"}, + {"operator": "IS", "term": "eventType", "value": "CREATED"}, + ], + }, + ], + "pgNum": 1, + "pgSize": 10000, + "srtDir": "asc", + "srtKey": "eventId", } query = FileEventQuery.from_dict(search_query) extraction_module.extract( diff --git a/tests/cmds/securitydata/test_main.py b/tests/cmds/securitydata/test_main.py index a10846756..6b67acbcd 100644 --- a/tests/cmds/securitydata/test_main.py +++ b/tests/cmds/securitydata/test_main.py @@ -86,11 +86,11 @@ def test_validation_when_is_advanced_query_and_other_incompatible_multi_narg_arg main.print_out(sdk, profile, file_event_namespace) -def test_validation_when_is_advanced_query_and_has_incremental_mode_exits( +def test_validation_when_is_advanced_query_and_uses_checkpoint_exits( sdk, profile, file_event_namespace ): file_event_namespace.advanced_query = "some complex json" - file_event_namespace.incremental = True + file_event_namespace.use_checkpoint = "foo" with pytest.raises(SystemExit): main.print_out(sdk, profile, file_event_namespace) @@ -122,9 +122,7 @@ def test_validation_when_is_saved_search_and_has_begin_date_exits( main.print_out(sdk, profile, file_event_namespace) -def test_validation_when_is_saved_search_and_has_end_date_exits( - sdk, profile, file_event_namespace -): +def test_validation_when_is_saved_search_and_has_end_date_exits(sdk, profile, file_event_namespace): file_event_namespace.saved_search = "abc" file_event_namespace.end = "end date" with pytest.raises(SystemExit): @@ -140,11 +138,11 @@ def test_validation_when_is_saved_search_and_has_exposure_types_exits( main.send_to(sdk, profile, file_event_namespace) -def test_validation_when_is_saved_search_and_has_incremental_mode_exits( +def test_validation_when_is_saved_search_and_uses_checkpoint_mode_exits( sdk, profile, file_event_namespace ): file_event_namespace.saved_search = "abc" - file_event_namespace.incremental = True + file_event_namespace.use_checkpoint = "foo" with pytest.raises(SystemExit): main.print_out(sdk, profile, file_event_namespace) @@ -157,6 +155,7 @@ def test_validation_when_is_saved_search_and_has_include_non_exposure_exits( with pytest.raises(SystemExit): main.print_out(sdk, profile, file_event_namespace) + @pytest.mark.parametrize( "arg", [ diff --git a/tests/conftest.py b/tests/conftest.py index b104d300e..58be055f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,7 @@ def file_event_namespace(): dict( sdk=mock_42, profile=create_mock_profile(), - incremental=None, + use_checkpoint=None, advanced_query=None, begin=None, end=None, @@ -54,7 +54,7 @@ def alert_namespace(): dict( sdk=mock_42, profile=create_mock_profile(), - incremental=None, + use_checkpoint=None, advanced_query=None, begin=None, end=None, @@ -142,7 +142,24 @@ def setup_mock_accessor(mock_accessor, name=None, values_dict=None): @pytest.fixture def profile(mocker): - return mocker.MagicMock(spec=Code42Profile) + mock = mocker.MagicMock(spec=Code42Profile) + mock.name = "testcliprofile" + return mock + + +@pytest.fixture(autouse=True) +def mock_makedirs(mocker): + return mocker.patch("os.makedirs") + + +@pytest.fixture(autouse=True) +def mock_remove(mocker): + return mocker.patch("os.remove") + + +@pytest.fixture(autouse=True) +def mock_listdir(mocker): + return mocker.patch("os.listdir") def func_keyword_args(one=None, two=None, three=None, default="testdefault", nargstest=[]): diff --git a/tests/test_completer.py b/tests/test_completer.py index fad0b06ca..ab8bd3b86 100644 --- a/tests/test_completer.py +++ b/tests/test_completer.py @@ -104,8 +104,8 @@ def test_complete_when_error_occurs_returns_empty_list(self, mocker): assert not actual def test_complete_when_completing_arg_works(self): - actual = self._completer.complete("code42 security-data print --incre") - assert "--incremental" in actual + actual = self._completer.complete("code42 security-data print --use-c") + assert "--use-checkpoint" in actual def test_complete_does_not_complete_positional_args(self): actual = self._completer.complete("code42 profile use nam") @@ -169,4 +169,4 @@ def test_complete_when_nothing_matches_files_return_nothing(self, files): def test_completer_ignore_shorthand_flagged_args(self): actual = self._completer.complete("code42 alerts write-to -") assert "-i" not in actual - assert "--incremental" in actual + assert "--use-checkpoint" in actual diff --git a/tests/test_main.py b/tests/test_main.py index e0722d5e2..3d5400d38 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -18,7 +18,7 @@ def test_getitem_when_at_alert_level_returns_alerts_subcommand_names(self): def test_getitem_returns_flagged_arg_names_when_is_leaf_command(self): loader = MainSubcommandLoader() args = loader[loader.ALERTS][u"print"] - assert "--incremental" in args + assert "--use-checkpoint" in args assert "--actor" in args def test_getitem_returns_choices_when_is_choice_based_arg(self): diff --git a/tests/test_parser.py b/tests/test_parser.py index 3a2fa28af..45f41fc90 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -149,15 +149,19 @@ def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_que ) -def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_incremental_mode_does_not_exit_as_invalid_args_does_not_contain_incremental(alert_namespace): +def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_uses_checkpoint_does_not_exit_as_invalid_args_does_not_contain_checkpoint( + alert_namespace +): alert_namespace.advanced_query = "some complex json" - alert_namespace.incremental = True + alert_namespace.use_checkpoint = "foo" exit_if_mutually_exclusive_args_used_together( alert_namespace, list(enums.AlertFilterArguments()) ) -def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_include_non_exposure_exits(file_event_namespace): +def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_include_non_exposure_exits( + file_event_namespace +): file_event_namespace.advanced_query = "some complex json" file_event_namespace.include_non_exposure = True with pytest.raises(SystemExit): @@ -166,7 +170,9 @@ def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_que ) -def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_format_does_not_exit(file_event_namespace): +def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_format_does_not_exit( + file_event_namespace +): file_event_namespace.advanced_query = "some complex json" file_event_namespace.format = "JSON" exit_if_mutually_exclusive_args_used_together( From 292a3077b8fee369c5520710e6f568d0faab3fb0 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 9 Jul 2020 10:06:21 -0500 Subject: [PATCH 085/349] Refactor/click (#107) * refactor profile * fix show * use profile_name_arg on all funcs that take one * fix profile_name args in functions * legal hold initial move to click * progress * more progress * functioning security-data * add clear-checkpoint * completed security-data and some misc refactoring * make incremental incompatible * refactor alerts * clean up --begin option validation * fix legalhold state/sdk args and delete a bunch of stuff * start on alert-rules * changes * some help strings and refactor bulk options * remove compat since py2 is no longer supported * start refactoring detectionlists * more refactoring detectionlists * changes * working refactor of departing-employee * HRE refactor plus etc. * fix handle_parse_result funcs to prevent errors on autocomplete * refactor generate_template to shared function * templates and help strings * legal hold help texts * main banner and more help texts * fix reset_pw, some work on refactoring logging exceptions * progress * more logging refactors * done refactoring logger/exceptions/printing * remove uneeded loggers, use echo instead of print in utils * bring in worker fix * refactor progress bar, remove all u-prefixes * progress bar labels and improved help texts * fix bad alerts strings from a pycharm refactor, and a better fix for logging exception handling when BrokenPipe encountered * clean up main * use echo for printing lh policy * correct create profile help * remove util.open_file and usage * encoding * change log_error * remove underscores from packages * line length * bring in saved search work * specify click and colorama min versions * pull in new cursor store changes * add cmd names in bulk helps * remove hyphens and change commands to actions in bulk helps * fix util tests * fix sdk, profile, and logger tests * fix config tests * remove unused imports * fix a bug in profile loading * improve read_flat_file * update generate_template_cmd_factory to allow for flat file templates * correct "remove" templates for DE and HRE to be flat files * fix bulk tests * optimize imports everywhere * fix profile cmd tests * add message to delete-all * fix deprecated callback arg usage * fix alert tests * add check for valid json in --advanced-query arg * fix advanced-query callback to handle None * narrow the exception catching * actually return the arg in the callback * format option correctly in advanced-query error * raise ClickException since it's just an input validation error * fix alerts tests * fix the rest of alert tests * alert-rules tests * make saved-search incompatible with advanced-query * oops, forgot to implement saved-search extraction * fix cursor_store tests * fix file reader callbacks * u prefixes * add bulk alert-rules tests * refactor some fixtures, add some tests, make sure cli_state obj is in all runs * refactor fixtures * move cursor_store and logger_factory tests and a few tweaks elsewhere * securitydata tests * legalhold tests * make runner a fixture, fix departing-employee tests * prevent opening of checkpoint file if --use-checkpoint not actually passed in * fix bug in adding risk tags * rest of fix for risk tags * remove future import * update `generate_template_cmd_factory` to take an arbitrary amount of commands, add `add-risk-tags`/`remove-risk-tags` templates to HRE bulk cmds * better bulk csv testing * fixed HRE tests * fix risk_tag splitting from csv input * add missing @global_options on HRE bulk commands, remove unused RiskTagError * fix test_bulk * fix sdk_client tests * fix py3.5 timezone error * account for potential race in bulk test call arg order * check correct method call * see if sleep fixes intermittent bulk test failure * another check to fix test? * see if side effect helps test * use len(call_args_list) instead of call_count for atomicity due to threads * bulk threading fix for departing employee * fix bug when no profiles exist * optimize imports * attempt thread-safe side effects * complete thread-safe side effects * rename @global_options to @sdk_options * refactor matter member printing * remove unused _current_row attr from BulkProcessor * rename private click command funcs, rework alert-rules `show` logic into _get_rule_type_func * only print inactive when option is passed * update changelog and readme --- CHANGELOG.md | 5 + README.md | 32 +- docs/conf.py | 8 +- integration/__init__.py | 6 +- integration/test_alerts.py | 42 +- integration/util.py | 7 +- setup.py | 5 +- src/code42cli/__init__.py | 4 +- src/code42cli/args.py | 120 --- src/code42cli/bulk.py | 137 ++-- src/code42cli/cmds/alert_rules.py | 188 +++++ src/code42cli/cmds/alerts.py | 254 +++++++ src/code42cli/cmds/alerts/extraction.py | 93 --- src/code42cli/cmds/alerts/main.py | 208 ------ src/code42cli/cmds/alerts/rules/__init__.py | 0 src/code42cli/cmds/alerts/rules/commands.py | 168 ----- src/code42cli/cmds/alerts/rules/enums.py | 4 - src/code42cli/cmds/alerts/rules/user_rule.py | 100 --- src/code42cli/cmds/alerts/util.py | 14 - src/code42cli/cmds/departing_employee.py | 100 +++ src/code42cli/cmds/detectionlists/__init__.py | 262 +------ src/code42cli/cmds/detectionlists/bulk.py | 39 - src/code42cli/cmds/detectionlists/commands.py | 185 ----- .../cmds/detectionlists/departing_employee.py | 64 -- src/code42cli/cmds/detectionlists/enums.py | 26 +- .../cmds/detectionlists/high_risk_employee.py | 110 --- src/code42cli/cmds/detectionlists/options.py | 10 + src/code42cli/cmds/high_risk_employee.py | 176 +++++ src/code42cli/cmds/legal_hold.py | 230 ++++++ src/code42cli/cmds/legal_hold/__init__.py | 146 ---- src/code42cli/cmds/legal_hold/commands.py | 168 ----- src/code42cli/cmds/profile.py | 287 +++----- .../cmds/{alerts => search}/__init__.py | 0 .../{search_shared => search}/cursor_store.py | 2 - src/code42cli/cmds/search/enums.py | 95 +++ .../{search_shared => search}/extraction.py | 62 +- .../logger_factory.py | 12 +- src/code42cli/cmds/search/options.py | 173 +++++ src/code42cli/cmds/search_shared/__init__.py | 0 src/code42cli/cmds/search_shared/args.py | 85 --- src/code42cli/cmds/search_shared/enums.py | 173 ----- src/code42cli/cmds/securitydata.py | 305 ++++++++ src/code42cli/cmds/securitydata/__init__.py | 0 src/code42cli/cmds/securitydata/extraction.py | 88 --- src/code42cli/cmds/securitydata/main.py | 219 ------ .../cmds/securitydata/savedsearch/__init__.py | 0 .../cmds/securitydata/savedsearch/commands.py | 33 - .../securitydata/savedsearch/savedsearch.py | 15 - src/code42cli/cmds/shared.py | 21 + src/code42cli/commands.py | 174 ----- src/code42cli/compat.py | 41 -- src/code42cli/completer.py | 81 --- src/code42cli/config.py | 32 +- src/code42cli/date_helper.py | 47 +- src/code42cli/errors.py | 161 +++-- src/code42cli/file_readers.py | 103 ++- src/code42cli/invoker.py | 155 ---- src/code42cli/logger.py | 194 +---- src/code42cli/main.py | 149 +--- src/code42cli/options.py | 118 +++ src/code42cli/parser.py | 137 ---- src/code42cli/password.py | 4 +- src/code42cli/profile.py | 65 +- src/code42cli/progress_bar.py | 29 - src/code42cli/sdk_client.py | 34 +- src/code42cli/tree_nodes.py | 104 --- src/code42cli/util.py | 100 +-- src/code42cli/worker.py | 37 +- tests/cmds/alerts/__init__.py | 0 tests/cmds/alerts/rules/__init__.py | 0 tests/cmds/alerts/rules/conftest.py | 13 - tests/cmds/alerts/rules/test_user_rule.py | 167 ----- tests/cmds/alerts/test_extraction.py | 367 ---------- tests/cmds/alerts/test_main.py | 103 --- tests/cmds/alerts/test_util.py | 54 -- tests/cmds/conftest.py | 111 ++- tests/cmds/detectionlists/__init__.py | 0 tests/cmds/detectionlists/conftest.py | 24 - tests/cmds/detectionlists/test_bulk.py | 32 - .../detectionlists/test_departing_employee.py | 81 --- .../detectionlists/test_high_risk_employee.py | 122 ---- tests/cmds/detectionlists/test_init.py | 290 -------- tests/cmds/legal_hold/__init__.py | 0 tests/cmds/legal_hold/test_legal_hold.py | 271 ------- tests/cmds/search_shared/__init__.py | 0 .../search_shared/test_advanced_query_args.py | 25 - tests/cmds/search_shared/test_date_helper.py | 147 ---- tests/cmds/securitydata/__init__.py | 0 .../cmds/securitydata/savedsearch/__init__.py | 0 .../securitydata/savedsearch/test_commands.py | 18 - .../savedsearch/test_savedsearch.py | 15 - tests/cmds/securitydata/test_extraction.py | 474 ------------ tests/cmds/securitydata/test_main.py | 179 ----- tests/cmds/test_alert_rules.py | 238 ++++++ tests/cmds/test_alerts.py | 621 ++++++++++++++++ tests/cmds/test_departing_employee.py | 141 ++++ tests/cmds/test_high_risk_employee.py | 198 +++++ tests/cmds/test_legal_hold.py | 351 +++++++++ tests/cmds/test_profile.py | 186 ++--- tests/cmds/test_securitydata.py | 684 ++++++++++++++++++ tests/cmds/test_shared.py | 9 + tests/conftest.py | 141 ++-- tests/test_args.py | 103 --- tests/test_bulk.py | 166 +---- tests/test_commands.py | 293 -------- tests/test_completer.py | 172 ----- tests/test_config.py | 27 +- .../search_shared => }/test_cursor_store.py | 20 +- tests/test_date_helper.py | 2 + tests/test_invoker.py | 180 ----- tests/test_logger.py | 94 +-- .../search_shared => }/test_logger_factory.py | 2 +- tests/test_main.py | 75 -- tests/test_parser.py | 180 ----- tests/test_profile.py | 26 +- tests/test_progress_bar.py | 29 - tests/test_sdk_client.py | 72 +- tests/test_tree_nodes.py | 63 -- tests/test_util.py | 26 +- 119 files changed, 4984 insertions(+), 7854 deletions(-) delete mode 100644 src/code42cli/args.py create mode 100644 src/code42cli/cmds/alert_rules.py create mode 100644 src/code42cli/cmds/alerts.py delete mode 100644 src/code42cli/cmds/alerts/extraction.py delete mode 100644 src/code42cli/cmds/alerts/main.py delete mode 100644 src/code42cli/cmds/alerts/rules/__init__.py delete mode 100644 src/code42cli/cmds/alerts/rules/commands.py delete mode 100644 src/code42cli/cmds/alerts/rules/enums.py delete mode 100644 src/code42cli/cmds/alerts/rules/user_rule.py delete mode 100644 src/code42cli/cmds/alerts/util.py create mode 100644 src/code42cli/cmds/departing_employee.py delete mode 100644 src/code42cli/cmds/detectionlists/bulk.py delete mode 100644 src/code42cli/cmds/detectionlists/commands.py delete mode 100644 src/code42cli/cmds/detectionlists/departing_employee.py delete mode 100644 src/code42cli/cmds/detectionlists/high_risk_employee.py create mode 100644 src/code42cli/cmds/detectionlists/options.py create mode 100644 src/code42cli/cmds/high_risk_employee.py create mode 100644 src/code42cli/cmds/legal_hold.py delete mode 100644 src/code42cli/cmds/legal_hold/__init__.py delete mode 100644 src/code42cli/cmds/legal_hold/commands.py rename src/code42cli/cmds/{alerts => search}/__init__.py (100%) rename src/code42cli/cmds/{search_shared => search}/cursor_store.py (98%) create mode 100644 src/code42cli/cmds/search/enums.py rename src/code42cli/cmds/{search_shared => search}/extraction.py (51%) rename src/code42cli/cmds/{search_shared => search}/logger_factory.py (89%) create mode 100644 src/code42cli/cmds/search/options.py delete mode 100644 src/code42cli/cmds/search_shared/__init__.py delete mode 100644 src/code42cli/cmds/search_shared/args.py delete mode 100644 src/code42cli/cmds/search_shared/enums.py create mode 100644 src/code42cli/cmds/securitydata.py delete mode 100644 src/code42cli/cmds/securitydata/__init__.py delete mode 100644 src/code42cli/cmds/securitydata/extraction.py delete mode 100644 src/code42cli/cmds/securitydata/main.py delete mode 100644 src/code42cli/cmds/securitydata/savedsearch/__init__.py delete mode 100644 src/code42cli/cmds/securitydata/savedsearch/commands.py delete mode 100644 src/code42cli/cmds/securitydata/savedsearch/savedsearch.py create mode 100644 src/code42cli/cmds/shared.py delete mode 100644 src/code42cli/commands.py delete mode 100644 src/code42cli/compat.py delete mode 100644 src/code42cli/completer.py delete mode 100644 src/code42cli/invoker.py create mode 100644 src/code42cli/options.py delete mode 100644 src/code42cli/progress_bar.py delete mode 100644 src/code42cli/tree_nodes.py delete mode 100644 tests/cmds/alerts/__init__.py delete mode 100644 tests/cmds/alerts/rules/__init__.py delete mode 100644 tests/cmds/alerts/rules/conftest.py delete mode 100644 tests/cmds/alerts/rules/test_user_rule.py delete mode 100644 tests/cmds/alerts/test_extraction.py delete mode 100644 tests/cmds/alerts/test_main.py delete mode 100644 tests/cmds/alerts/test_util.py delete mode 100644 tests/cmds/detectionlists/__init__.py delete mode 100644 tests/cmds/detectionlists/conftest.py delete mode 100644 tests/cmds/detectionlists/test_bulk.py delete mode 100644 tests/cmds/detectionlists/test_departing_employee.py delete mode 100644 tests/cmds/detectionlists/test_high_risk_employee.py delete mode 100644 tests/cmds/detectionlists/test_init.py delete mode 100644 tests/cmds/legal_hold/__init__.py delete mode 100644 tests/cmds/legal_hold/test_legal_hold.py delete mode 100644 tests/cmds/search_shared/__init__.py delete mode 100644 tests/cmds/search_shared/test_advanced_query_args.py delete mode 100644 tests/cmds/search_shared/test_date_helper.py delete mode 100644 tests/cmds/securitydata/__init__.py delete mode 100644 tests/cmds/securitydata/savedsearch/__init__.py delete mode 100644 tests/cmds/securitydata/savedsearch/test_commands.py delete mode 100644 tests/cmds/securitydata/savedsearch/test_savedsearch.py delete mode 100644 tests/cmds/securitydata/test_extraction.py delete mode 100644 tests/cmds/securitydata/test_main.py create mode 100644 tests/cmds/test_alert_rules.py create mode 100644 tests/cmds/test_alerts.py create mode 100644 tests/cmds/test_departing_employee.py create mode 100644 tests/cmds/test_high_risk_employee.py create mode 100644 tests/cmds/test_legal_hold.py create mode 100644 tests/cmds/test_securitydata.py create mode 100644 tests/cmds/test_shared.py delete mode 100644 tests/test_args.py delete mode 100644 tests/test_commands.py delete mode 100644 tests/test_completer.py rename tests/{cmds/search_shared => }/test_cursor_store.py (90%) delete mode 100644 tests/test_invoker.py rename tests/{cmds/search_shared => }/test_logger_factory.py (98%) delete mode 100644 tests/test_main.py delete mode 100644 tests/test_parser.py delete mode 100644 tests/test_progress_bar.py delete mode 100644 tests/test_tree_nodes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 941fecabc..b95769497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Changed - `-i` (`--incremental`) has been removed, use `-c` (`--use-checkpoint`) with a string name for the checkpoint instead. +- The code42cli has been migrated to the [click](https://click.palletsprojects.com) framework. This brings: + - BREAKING CHANGE: Commands that accept multiple values for the same option now must have the option flag provided + before each value: + `--option value1 --option value2` instead of `--option value1 value2` (which was previously possible). + - Cosmetic changes to error messages, progress bars, and help message formatting. ### Added diff --git a/README.md b/README.md index 21aed21b2..57ae9daea 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Use the `code42` command to interact with your Code42 environment. ## Requirements -- Python 2.7.x or 3.5.0+ +- Python 3.5.0+ - Code42 Server 6.8.x+ ## Installation @@ -18,7 +18,7 @@ Use the `code42` command to interact with your Code42 environment. Install the `code42` CLI using: ```bash -$ python setup.py install +$ python3 -m pip install code42cli ``` ## Usage @@ -144,7 +144,7 @@ This is only guaranteed if you did not change your query. To send events to a server using a specific profile, do: ```bash -code42 security-data send-to --profile PROFILE_FOR_RECURRING_JOB syslog.company.com -b 2020-02-02 -f CEF -i +code42 security-data --profile PROFILE_FOR_RECURRING_JOB send-to syslog.company.com -b 2020-02-02 -f CEF -i ``` You can also use wildcard for queries, but note, if they are not in quotes, you may get unexpected behavior. @@ -173,6 +173,8 @@ Each destination-type subcommand shares query parameters You cannot use other query parameters if you use `--advanced-query`. To learn more about acceptable arguments, add the `-h` flag to `code42` or any of the destination-type subcommands. + + ## Detection Lists You can both add and remove employees from detection lists using the CLI. This example uses `high-risk-employee`. @@ -212,16 +214,22 @@ If that doesn't work, delete your credentials file located at ~/.code42cli or th ## Tab completion -For `zsh`, add these commands to your `.zshrc` file: +For Bash, add this to ~/.bashrc: -```bash -C42_COMPLETER=$(which code42cli_completer) -autoload bashcompinit && bashcompinit -complete -C '$C42_COMPLETER' code42 +``` +eval "$(_CODE42_COMPLETE=source_bash code42)" ``` -For bash, add just the first and last commands to your `.bash_profile`: -```bash -C42_COMPLETER=$(which code42cli_completer) -complete -C '$C42_COMPLETER' code42 +For Zsh, add this to ~/.zshrc: + ``` +eval "$(_CODE42_COMPLETE=source_zsh code42)" +``` + +For Fish, add this to ~/.config/fish/completions/code42.fish: + +``` +eval (env _CODE42_COMPLETE=source_fish code42) +``` + +Open a new shell to enable completion. Or run the eval command directly in your current shell to enable it temporarily. diff --git a/docs/conf.py b/docs/conf.py index 8e7f4ed3b..1c14cc09b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,9 +21,9 @@ # -- Project information ----------------------------------------------------- -project = u"code42cli" -copyright = u"2020, Code42 Software" -author = u"Code42 Software" +project = "code42cli" +copyright = "2020, Code42 Software" +author = "Code42 Software" # The short X.Y version version = "code42cli v{}".format(meta.__version__) @@ -63,7 +63,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = [u"_build", "Thumbs.db", ".DS_Store"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None diff --git a/integration/__init__.py b/integration/__init__.py index 01c378121..83afd82fc 100644 --- a/integration/__init__.py +++ b/integration/__init__.py @@ -2,9 +2,9 @@ import pexpect -LINE_FEED = b'\r\n' -PASSWORD_PROMPT = b'Password: ' -ENCODING_TYPE = 'utf-8' +LINE_FEED = b"\r\n" +PASSWORD_PROMPT = b"Password: " +ENCODING_TYPE = "utf-8" def encode_response(line, encoding_type=ENCODING_TYPE): diff --git a/integration/test_alerts.py b/integration/test_alerts.py index 892f2de83..bb6369ed6 100644 --- a/integration/test_alerts.py +++ b/integration/test_alerts.py @@ -20,17 +20,27 @@ def _validate_field_value(field, value, response): @pytest.mark.parametrize( "command, field, value", - [("{} --state OPEN".format(ALERT_COMMAND), "state", "OPEN"), - ("{} --state RESOLVED".format(ALERT_COMMAND), "state", "RESOLVED"), - ("{} --actor spatel@code42.com".format(ALERT_COMMAND), "actor", "spatel@code42.com"), - ("{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), "name", "File Upload Alert"), - ("{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND), "ruleId", - "962a6a1c-54f6-4477-90bd-a08cc74cbf71"), - ("{} --rule-type FedEndpointExfiltration".format(ALERT_COMMAND), "type", - "FED_ENDPOINT_EXFILTRATION"), - ("{} --description 'Alert on any file upload'".format(ALERT_COMMAND), "description", - "Alert on any file upload events"), - ] + [ + ("{} --state OPEN".format(ALERT_COMMAND), "state", "OPEN"), + ("{} --state RESOLVED".format(ALERT_COMMAND), "state", "RESOLVED"), + ("{} --actor spatel@code42.com".format(ALERT_COMMAND), "actor", "spatel@code42.com"), + ("{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), "name", "File Upload Alert"), + ( + "{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND), + "ruleId", + "962a6a1c-54f6-4477-90bd-a08cc74cbf71", + ), + ( + "{} --rule-type FedEndpointExfiltration".format(ALERT_COMMAND), + "type", + "FED_ENDPOINT_EXFILTRATION", + ), + ( + "{} --description 'Alert on any file upload'".format(ALERT_COMMAND), + "description", + "Alert on any file upload events", + ), + ], ) def test_alert_prints_to_stdout_and_filters_result_by_given_value(command, field, value): return_code, response = run_command(command) @@ -45,9 +55,7 @@ def _validate_begin_date(response): assert record["createdAt"].startswith("2020-05-18") -@pytest.mark.parametrize("command, validate", [ - (ALERT_COMMAND, _validate_begin_date), -]) +@pytest.mark.parametrize("command, validate", [(ALERT_COMMAND, _validate_begin_date),]) def test_alert_prints_to_stdout_and_filters_result_between_given_date(command, validate): return_code, response = run_command(command) assert return_code is 0 @@ -61,7 +69,9 @@ def _validate_severity(response): @cleanup_after_validation("./integration/alerts") def test_alert_writes_to_file_and_filters_result_by_severity(): - command = "code42 alerts write-to ./integration/alerts -b 2020-05-18 -e 2020-05-20 " \ - "--severity MEDIUM" + command = ( + "code42 alerts write-to ./integration/alerts -b 2020-05-18 -e 2020-05-20 " + "--severity MEDIUM" + ) return_code, response = run_command(command) return _validate_severity diff --git a/integration/util.py b/integration/util.py index 1f5b139d1..a0e0c90d3 100644 --- a/integration/util.py +++ b/integration/util.py @@ -2,12 +2,12 @@ class cleanup(object): - def __init__(self, filename): + def __init__(self, filename): self.filename = filename def __enter__(self): return open(self.filename, "r") - + def __exit__(self, exc_type, exc_val, exc_tb): os.remove(self.filename) @@ -19,11 +19,14 @@ def cleanup_after_validation(filename): The decorated function should return validation function that takes the content of the file as input. e.g `test_alerts.py::test_alert_writes_to_file_and_filters_result_by_severity` """ + def wrap(test_function): def wrapper(): validate = test_function() with cleanup(filename) as f: response = f.read() validate(response) + return wrapper + return wrap diff --git a/setup.py b/setup.py index 23621da48..77a48c0c8 100644 --- a/setup.py +++ b/setup.py @@ -21,9 +21,12 @@ package_dir={"": "src"}, python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ + "click>=7.1.1", + "colorama>=0.4.3", "c42eventextractor==0.3.2", "keyring==18.0.1", "keyrings.alt==3.2.0", + "py42>=1.5.1", ], license="MIT", include_package_data=True, @@ -54,5 +57,5 @@ "Programming Language :: Python :: Implementation :: CPython", ], scripts=["bin/code42cli_completer"], - entry_points={"console_scripts": ["code42=code42cli.main:main"]}, + entry_points={"console_scripts": ["code42=code42cli.main:cli"]}, ) diff --git a/src/code42cli/__init__.py b/src/code42cli/__init__.py index ff01da097..7cbf87816 100644 --- a/src/code42cli/__init__.py +++ b/src/code42cli/__init__.py @@ -1,2 +1,2 @@ -PRODUCT_NAME = u"code42cli" -MAIN_COMMAND = u"code42" +PRODUCT_NAME = "code42cli" +MAIN_COMMAND = "code42" diff --git a/src/code42cli/args.py b/src/code42cli/args.py deleted file mode 100644 index 826dee193..000000000 --- a/src/code42cli/args.py +++ /dev/null @@ -1,120 +0,0 @@ -from collections import OrderedDict -import inspect - - -PROFILE_HELP = u"The name of the Code42 CLI profile to use when executing this command." -SDK_ARG_NAME = u"sdk" -PROFILE_ARG_NAME = u"profile" - - -class ArgConfig(object): - """Stores a set of argparse commands for later use by a command.""" - - def __init__(self, *args, **kwargs): - self._settings = { - u"action": kwargs.get(u"action"), - u"choices": kwargs.get(u"choices"), - u"default": kwargs.get(u"default"), - u"help": kwargs.get(u"help"), - u"options_list": list(args), - u"nargs": kwargs.get(u"nargs"), - u"metavar": kwargs.get(u"metavar"), - u"required": kwargs.get(u"required"), - } - - @property - def settings(self): - return self._settings - - def set_choices(self, choices): - self._settings[u"choices"] = choices - - def set_help(self, help): - self._settings[u"help"] = help - - def add_short_option_name(self, short_name): - self._settings[u"options_list"].append(short_name) - - def as_multi_val_param(self, nargs=u"+"): - self._settings[u"nargs"] = nargs - - def set_required(self, required): - self._settings[u"required"] = required - - -class ArgConfigCollection(object): - def __init__(self): - self._arg_configs = OrderedDict() - - @property - def arg_configs(self): - return self._arg_configs - - def append(self, name, arg_config): - self._arg_configs[name] = arg_config - - def extend(self, arg_config_dict): - self.arg_configs.update(arg_config_dict) - - -def get_auto_arg_configs(handler): - """Looks at the parameter names of `handler` and builds an `ArgConfigCollection` containing - `argparse` parameters based on them.""" - arg_configs = ArgConfigCollection() - excluded_args = [SDK_ARG_NAME, u"profile", u"args", u"kwargs", u"self"] - if callable(handler): - argspec = inspect.getargspec(handler) - filtered_argspec = { - key: position for position, key in enumerate(argspec.args) if key not in excluded_args - } - num_optional_args = len(argspec.defaults) if argspec.defaults else 0 - num_positional_args = len(argspec.args) - num_optional_args - num_required_cli_args = len(filtered_argspec) - num_optional_args - - for key in filtered_argspec: - arg_config = _create_auto_args_config( - key, filtered_argspec[key], num_positional_args, num_required_cli_args, argspec - ) - _set_smart_defaults(arg_config) - arg_configs.append(key, arg_config) - - if SDK_ARG_NAME in argspec.args: - _build_sdk_arg_configs(arg_configs) - - return arg_configs - - -def _create_auto_args_config(key, position, num_positional_args, num_required_cli_args, argspec): - default = None - required = None - param_name = key.replace(u"_", u"-") - last_positional_arg_idx = num_positional_args - 1 - option_names = [u"--{}".format(param_name)] - # positional arguments will come first, so if the arg position - # is greater than the index of the last positional arg, it's a kwarg. - if position > last_positional_arg_idx: - # this is a keyword arg, treat it as an optional cli arg. - default_value = argspec.defaults[position - num_positional_args] - default = default_value - elif num_required_cli_args > 1: - # this is a positional arg, treat it as a required cli arg. - required = True - else: - option_names = [param_name] - return ArgConfig(*option_names, default=default, required=required) - - -def _set_smart_defaults(arg_config): - default = arg_config.settings.get(u"default") - # make the param not require a value (e.g. --enable) if the default value of - # the param is a bool. - if type(default) == bool: - arg_config.settings[u"action"] = u"store_{}".format(default).lower() - - -def _build_sdk_arg_configs(arg_config_collection): - """Add extra cli parameters that will always be relevant when a handler needs the sdk.""" - profile = ArgConfig(u"--profile", help=PROFILE_HELP) - debug = ArgConfig(u"-d", u"--debug", action=u"store_true", help=u"Turn on Debug logging.") - extras = {PROFILE_ARG_NAME: profile, u"debug": debug} - arg_config_collection.extend(extras) diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index a94c91394..e829d01d2 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -1,68 +1,86 @@ -import os, inspect +import os -from code42cli.compat import open, str -from code42cli.worker import Worker -from code42cli.logger import get_main_cli_logger -from code42cli.args import SDK_ARG_NAME, PROFILE_ARG_NAME -from code42cli.progress_bar import ProgressBar +import click +from code42cli.errors import LoggedCLIError +from code42cli.logger import get_main_cli_logger +from code42cli.worker import Worker _logger = get_main_cli_logger() class BulkCommandType(object): - ADD = u"add" - REMOVE = u"remove" + ADD = "add" + REMOVE = "remove" def __iter__(self): return iter([self.ADD, self.REMOVE]) -def generate_template(handler, path=None): - """Looks at the parameter names of `handler` and creates a file with the same column names. If - `handler` only has one parameter that is not `sdk` or `profile`, it will create a blank file. - This is useful for commands such as `remove` which only require a list of users. - """ - path = path or os.path.join(os.getcwd(), u"{}.csv".format(str(handler.__name__))) - args = [ - arg - for arg in inspect.getargspec(handler).args - if arg != SDK_ARG_NAME and arg != PROFILE_ARG_NAME - ] - - if len(args) <= 1: - _logger.print_info( - u"A blank file was generated because there are no csv headers needed for this command. " - u"Simply enter one {} per line.".format(args[0]) - ) - # Set args to None so that we don't make a header out of the single arg. - args = None - - _write_template_file(path, args) - - -def _write_template_file(path, columns=None): - with open(path, u"w", encoding=u"utf8") as new_file: +def write_template_file(path, columns=None, flat_item=None): + with open(path, "w", encoding="utf8") as new_file: if columns: - new_file.write(u",".join(columns)) - + new_file.write(",".join(columns)) + else: + new_file.write( + "# This template takes a single {} to be processed on each row.".format( + flat_item or "item" + ) + ) + + +def generate_template_cmd_factory(group_name, commands_dict): + """Helper function that creates a `generate-template` click command that can be added to `bulk` + sub-command groups. + + Args: + `group_name`: a str representing the parent command group this is generating templates for. + `commands_dict`: a dict of the commands with their column names. Keys are the cmd + names that will become the `cmd` argument, and values are the list of column names for + the csv. + + If a cmd takes a flat file, value should be a string indicating what item the flat file + rows should contain. + """ -def run_bulk_process(row_handler, reader): + @click.command() + @click.argument("cmd", type=click.Choice(list(commands_dict))) + @click.argument( + "path", required=False, type=click.Path(dir_okay=False, resolve_path=True, writable=True) + ) + def generate_template(cmd, path): + """\b + Generate the csv template needed for bulk adding/removing users. + + Optional PATH argument can be provided to write to a specific file path/name. + """ + columns = commands_dict[cmd] + if not path: + filename = "{}_bulk_{}.csv".format(group_name, cmd.replace("-", "_")) + path = os.path.join(os.getcwd(), filename) + if isinstance(columns, str): + write_template_file(path, columns=None, flat_item=columns) + else: + write_template_file(path, columns=columns) + + return generate_template + + +def run_bulk_process(row_handler, rows, progress_label=None): """Runs a bulk process. Args: row_handler (callable): A callable that you define to process values from the row as either *args or **kwargs. - reader: (CSVReader or FlatFileReader, optional): A generator that reads rows and yields data into - `row_handler`. If None, it will use a CSVReader. Defaults to None. + rows (iterable): the rows to process. """ - processor = _create_bulk_processor(row_handler, reader) + processor = _create_bulk_processor(row_handler, rows, progress_label) processor.run() -def _create_bulk_processor(row_handler, reader): +def _create_bulk_processor(row_handler, rows, progress_label): """A factory method to create the bulk processor, useful for testing purposes.""" - return BulkProcessor(row_handler, reader) + return BulkProcessor(row_handler, rows, progress_label=progress_label) class BulkProcessor(object): @@ -77,21 +95,21 @@ class BulkProcessor(object): reader (CSVReader or FlatFileReader): A generator that reads rows and yields data into `row_handler`. """ - def __init__(self, row_handler, reader, worker=None, progress_bar=None): - total = reader.get_rows_count() - self.file_path = reader.file_path + def __init__(self, row_handler, rows, worker=None, progress_label=None): + total = len(rows) + self._rows = rows self._row_handler = row_handler - self._reader = reader - self.__worker = worker or Worker(5, total) + self._progress_bar = click.progressbar( + length=len(self._rows), item_show_func=self._show_stats, label=progress_label + ) + self.__worker = worker or Worker(5, total, bar=self._progress_bar) self._stats = self.__worker.stats - self._progress_bar = progress_bar or ProgressBar(total) def run(self): - """Processes the csv file specified in the ctor, calling `self.row_handler` on each row.""" - with open(self.file_path, newline=u"", encoding=u"utf8") as bulk_file: - for row in self._reader(bulk_file=bulk_file): - self._process_row(row) - self.__worker.wait() + """Processes the csv rows specified in the ctor, calling `self.row_handler` on each row.""" + for row in self._rows: + self._process_row(row) + self.__worker.wait() self._print_results() def _process_row(self, row): @@ -101,10 +119,11 @@ def _process_row(self, row): self._process_flat_file_row(row.strip()) def _process_csv_row(self, row): - # Removes problems from including extra comments. Error messages from out of order args + # Removes problems from including extra columns. Error messages from out of order args # are more indicative this way too. row.pop(None, None) - row_values = {key: val if val != u"" else None for key, val in row.items()} + + row_values = {key: val if val != "" else None for key, val in row.items()} self.__worker.do_async( lambda *args, **kwargs: self._handle_row(*args, **kwargs), **row_values ) @@ -114,12 +133,12 @@ def _process_flat_file_row(self, row): self.__worker.do_async(lambda *args, **kwargs: self._handle_row(*args, **kwargs), row) def _handle_row(self, *args, **kwargs): - message = str(self._stats) - self._progress_bar.update(self._stats.total_processed, message) self._row_handler(*args, **kwargs) + def _show_stats(self, _): + return str(self._stats) + def _print_results(self): - self._progress_bar.clear_bar_and_print_final(str(self._stats)) + click.echo("") if self._stats.total_errors: - logger = get_main_cli_logger() - logger.print_errors_occurred_message() + raise LoggedCLIError("Some problems occurred during bulk processing.") diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py new file mode 100644 index 000000000..ca77f5ec5 --- /dev/null +++ b/src/code42cli/cmds/alert_rules.py @@ -0,0 +1,188 @@ +from collections import OrderedDict + +import click +from click import echo +from py42.exceptions import Py42InternalServerError +from py42.util import format_json + +from code42cli import PRODUCT_NAME +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process +from code42cli.cmds.shared import get_user_id +from code42cli.errors import Code42CLIError, InvalidRuleTypeError +from code42cli.file_readers import read_csv_arg +from code42cli.options import sdk_options, OrderedGroup +from code42cli.util import format_to_table, find_format_width + + +class AlertRuleTypes(object): + EXFILTRATION = "FED_ENDPOINT_EXFILTRATION" + CLOUD_SHARE = "FED_CLOUD_SHARE_PERMISSIONS" + FILE_TYPE_MISMATCH = "FED_FILE_TYPE_MISMATCH" + + +_HEADER_KEYS_MAP = OrderedDict() +_HEADER_KEYS_MAP["observerRuleId"] = "RuleId" +_HEADER_KEYS_MAP["name"] = "Name" +_HEADER_KEYS_MAP["severity"] = "Severity" +_HEADER_KEYS_MAP["type"] = "Type" +_HEADER_KEYS_MAP["ruleSource"] = "Source" +_HEADER_KEYS_MAP["isEnabled"] = "Enabled" + + +@click.group(cls=OrderedGroup) +@sdk_options +def alert_rules(state): + """Manage alert rules.""" + pass + + +rule_id_option = click.option("--rule-id", required=True, help="Observer ID of the rule.") +username_option = click.option("-u", "--username", required=True) + + +@alert_rules.command() +@rule_id_option +@click.option( + "-u", "--username", required=True, help="The username of the user to add to the alert rule.", +) +@sdk_options +def add_user(state, rule_id, username): + """Add a user to an alert rule.""" + _add_user(state.sdk, rule_id, username) + + +@alert_rules.command() +@rule_id_option +@click.option( + "-u", + "--username", + required=True, + help="The username of the user to remove from the alert rule.", +) +@sdk_options +def remove_user(state, rule_id, username): + """Remove a user from an alert rule.""" + _remove_user(state.sdk, rule_id, username) + + +@alert_rules.command("list") +@sdk_options +def list_alert_rules(state): + """Fetch existing alert rules.""" + selected_rules = _get_all_rules_metadata(state.sdk) + if selected_rules: + rows, column_size = find_format_width(selected_rules, _HEADER_KEYS_MAP) + format_to_table(rows, column_size) + + +@alert_rules.command() +@click.argument("rule_id") +@sdk_options +def show(state, rule_id): + """Print out detailed alert rule criteria.""" + selected_rule = _get_rule_metadata(state.sdk, rule_id) + if selected_rule: + get = _get_rule_type_func(state.sdk, selected_rule[0]["type"]) + rule_detail = get(rule_id) + echo(format_json(rule_detail.text)) + + +@alert_rules.group(cls=OrderedGroup) +@sdk_options +def bulk(state): + """Tools for executing bulk alert rule actions.""" + pass + + +ALERT_RULES_CSV_HEADERS = ["rule_id", "username"] + +alert_rules_generate_template = generate_template_cmd_factory( + group_name="alert_rules", + commands_dict={"add": ALERT_RULES_CSV_HEADERS, "remove": ALERT_RULES_CSV_HEADERS}, +) +bulk.add_command(alert_rules_generate_template) + + +@bulk.command( + help="Bulk add users to alert rules from a csv file. CSV file format: {}".format( + ",".join(ALERT_RULES_CSV_HEADERS) + ) +) +@read_csv_arg(headers=ALERT_RULES_CSV_HEADERS) +@sdk_options +def add(state, csv_rows): + row_handler = lambda rule_id, username: _add_user(state.sdk, rule_id, username) + run_bulk_process(row_handler, csv_rows, progress_label="Adding users to alert-rules:") + + +@bulk.command( + help="Bulk remove users from alert rules from a csv file. CSV file format: {}".format( + ",".join(ALERT_RULES_CSV_HEADERS) + ) +) +@read_csv_arg(headers=ALERT_RULES_CSV_HEADERS) +@sdk_options +def remove(state, csv_rows): + row_handler = lambda rule_id, username: _remove_user(state.sdk, rule_id, username) + run_bulk_process(row_handler, csv_rows, progress_label="Removing users from alert-rules:") + + +def _add_user(sdk, rule_id, username): + user_id = get_user_id(sdk, username) + rules = _get_rule_metadata(sdk, rule_id) + try: + if rules: + sdk.alerts.rules.add_user(rule_id, user_id) + except Py42InternalServerError as e: + _check_if_system_rule(rules) + raise + + +def _remove_user(sdk, rule_id, username): + user_id = get_user_id(sdk, username) + rules = _get_rule_metadata(sdk, rule_id) + try: + if rules: + sdk.alerts.rules.remove_user(rule_id, user_id) + except Py42InternalServerError as e: + _check_if_system_rule(rules) + raise + + +def _get_all_rules_metadata(sdk): + rules_generator = sdk.alerts.rules.get_all() + selected_rules = [rule for rules in rules_generator for rule in rules["ruleMetadata"]] + return _handle_rules_results(selected_rules) + + +def _get_rule_metadata(sdk, rule_id): + rules = sdk.alerts.rules.get_by_observer_id(rule_id)["ruleMetadata"] + return _handle_rules_results(rules, rule_id) + + +def _handle_rules_results(rules, rule_id=None): + id_msg = "with RuleId {} ".format(rule_id) if rule_id else "" + msg = "No alert rules {0}found.".format(id_msg) + if not rules: + echo(msg) + return rules + + +def _check_if_system_rule(rules): + if rules and rules[0]["isSystem"]: + raise InvalidRuleTypeError(rules[0]["observerRuleId"], rules[0]["ruleSource"]) + + +def _get_rule_type_func(sdk, rule_type): + if rule_type == AlertRuleTypes.EXFILTRATION: + return sdk.alerts.rules.exfiltration.get + elif rule_type == AlertRuleTypes.CLOUD_SHARE: + return sdk.alerts.rules.cloudshare.get + elif rule_type == AlertRuleTypes.FILE_TYPE_MISMATCH: + return sdk.alerts.rules.filetypemismatch.get + else: + raise Code42CLIError( + "Received an unknown rule type from server. You might need to update " + "to a newer version of {}".format(PRODUCT_NAME) + ) diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py new file mode 100644 index 000000000..e71c73816 --- /dev/null +++ b/src/code42cli/cmds/alerts.py @@ -0,0 +1,254 @@ +import click +from c42eventextractor.extractors import AlertExtractor +from click import echo +from py42.sdk.queries.alerts.filters import * + +import code42cli.errors as errors +from code42cli.cmds.search import logger_factory +from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.cmds.search.enums import ( + AlertOutputFormat, + AlertSeverity as AlertSeverityOptions, + AlertState as AlertStateOptions, + RuleType as RuleTypeOptions, +) +from code42cli.cmds.search.extraction import ( + create_handlers, + create_time_range_filter, +) +from code42cli.cmds.search.options import ( + create_search_options, + AdvancedQueryAndSavedSearchIncompatible, + is_in_filter, + contains_filter, + not_contains_filter, + not_in_filter, + output_file_arg, + server_options, +) +from code42cli.options import sdk_options, OrderedGroup + +search_options = create_search_options("alerts") + +format_option = click.option( + "-f", + "--format", + type=click.Choice(AlertOutputFormat()), + default=AlertOutputFormat.JSON, + help="The format used for outputting alerts.", +) +severity_option = click.option( + "--severity", + multiple=True, + type=click.Choice(AlertSeverityOptions()), + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=is_in_filter(Severity), + help="Filter alerts by severity. Defaults to returning all severities.", +) +state_option = click.option( + "--state", + multiple=True, + type=click.Choice(AlertStateOptions()), + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=is_in_filter(AlertState), + help="Filter alerts by state. Defaults to returning all states.", +) +actor_option = click.option( + "--actor", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=is_in_filter(Actor), + help="Filter alerts by including the given actor(s) who triggered the alert. " + "Args must match actor username exactly.", +) +actor_contains_option = click.option( + "--actor-contains", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=contains_filter(Actor), + help="Filter alerts by including actor(s) whose username contains the given string.", +) +exclude_actor_option = click.option( + "--exclude-actor", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=not_in_filter(Actor), + help="Filter alerts by excluding the given actor(s) who triggered the alert. " + "Args must match actor username exactly.", +) +exclude_actor_contains_option = click.option( + "--exclude-actor-contains", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=not_contains_filter(Actor), + help="Filter alerts by excluding actor(s) whose username contains the given string.", +) +rule_name_option = click.option( + "--rule-name", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=is_in_filter(RuleName), + help="Filter alerts by including the given rule name(s).", +) +exclude_rule_name_option = click.option( + "--exclude-rule-name", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=not_in_filter(RuleName), + help="Filter alerts by excluding the given rule name(s).", +) +rule_id_option = click.option( + "--rule-id", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=is_in_filter(RuleId), + help="Filter alerts by including the given rule id(s).", +) +exclude_rule_id_option = click.option( + "--exclude-rule-id", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=not_in_filter(RuleId), + help="Filter alerts by excluding the given rule id(s).", +) +rule_type_option = click.option( + "--rule-type", + multiple=True, + type=click.Choice(RuleTypeOptions()), + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=is_in_filter(RuleType), + help="Filter alerts by including the given rule type(s).", +) +exclude_rule_type_option = click.option( + "--exclude-rule-type", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=not_in_filter(RuleType), + help="Filter alerts by excluding the given rule type(s).", +) +description_option = click.option( + "--description", + multiple=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=contains_filter(Description), + help="Filter alerts by description. Does fuzzy search by default.", +) + + +def alert_options(f): + f = actor_option(f) + f = actor_contains_option(f) + f = exclude_actor_option(f) + f = exclude_actor_contains_option(f) + f = rule_name_option(f) + f = exclude_rule_name_option(f) + f = rule_id_option(f) + f = exclude_rule_id_option(f) + f = rule_type_option(f) + f = exclude_rule_type_option(f) + f = description_option(f) + f = severity_option(f) + f = state_option(f) + f = format_option(f) + return f + + +@click.group(cls=OrderedGroup) +@sdk_options +def alerts(state): + """Tools for getting alert data.""" + # store cursor getter on the group state so shared --begin option can use it in validation + state.cursor_getter = _get_alert_cursor_store + + +@alerts.command() +@click.argument("checkpoint-name") +@sdk_options +def clear_checkpoint(state, checkpoint_name): + """Remove the saved alert checkpoint from '--use-checkpoint/-c' mode.""" + _get_alert_cursor_store(state.profile.name).delete(checkpoint_name) + + +@alerts.command("print") +@alert_options +@search_options +@sdk_options +def print_alerts(cli_state, format, begin, end, advanced_query, use_checkpoint, **kwargs): + """Print alerts to stdout.""" + output_logger = logger_factory.get_logger_for_stdout(format) + cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None + _extract( + sdk=cli_state.sdk, + cursor=cursor, + checkpoint_name=use_checkpoint, + filter_list=cli_state.search_filters, + begin=begin, + end=end, + advanced_query=advanced_query, + output_logger=output_logger, + ) + + +@alerts.command() +@output_file_arg +@alert_options +@search_options +@sdk_options +def write_to(cli_state, format, output_file, begin, end, advanced_query, use_checkpoint, **kwargs): + """Write alerts to the file with the given name.""" + output_logger = logger_factory.get_logger_for_file(output_file, format) + cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None + _extract( + sdk=cli_state.sdk, + cursor=cursor, + checkpoint_name=use_checkpoint, + filter_list=cli_state.search_filters, + begin=begin, + end=end, + advanced_query=advanced_query, + output_logger=output_logger, + ) + + +@alerts.command() +@server_options +@alert_options +@search_options +@sdk_options +def send_to( + cli_state, format, hostname, protocol, begin, end, advanced_query, use_checkpoint, **kwargs +): + """Send alerts to the given server address.""" + output_logger = logger_factory.get_logger_for_server(hostname, protocol, format) + cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None + _extract( + sdk=cli_state.sdk, + cursor=cursor, + checkpoint_name=use_checkpoint, + filter_list=cli_state.search_filters, + begin=begin, + end=end, + advanced_query=advanced_query, + output_logger=output_logger, + ) + + +def _extract(sdk, cursor, checkpoint_name, filter_list, begin, end, advanced_query, output_logger): + handlers = create_handlers(sdk, AlertExtractor, output_logger, cursor, checkpoint_name) + extractor = _get_alert_extractor(sdk, handlers) + if advanced_query: + extractor.extract_advanced(advanced_query) + else: + if begin or end: + filter_list.append(create_time_range_filter(DateObserved, begin, end)) + extractor.extract(*filter_list) + if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: + echo("No results found.") + + +def _get_alert_extractor(sdk, handlers): + return AlertExtractor(sdk, handlers) + + +def _get_alert_cursor_store(profile_name): + return AlertCursorStore(profile_name) diff --git a/src/code42cli/cmds/alerts/extraction.py b/src/code42cli/cmds/alerts/extraction.py deleted file mode 100644 index 0b5c17eb9..000000000 --- a/src/code42cli/cmds/alerts/extraction.py +++ /dev/null @@ -1,93 +0,0 @@ -from c42eventextractor.extractors import AlertExtractor -from py42.sdk.queries.alerts.filters import ( - Actor, - AlertState, - Severity, - DateObserved, - Description, - RuleName, - RuleId, - RuleType, -) - -import code42cli.cmds.search_shared.enums as enums -import code42cli.errors as errors -from code42cli.cmds.search_shared.cursor_store import AlertCursorStore -from code42cli.cmds.search_shared.extraction import ( - verify_begin_date_requirements, - create_handlers, - create_time_range_filter, -) -from code42cli.logger import get_main_cli_logger - -logger = get_main_cli_logger() - - -def extract(sdk, profile, output_logger, args): - """Extracts alerts using the given command-line arguments. - - Args: - sdk (py42.sdk.SDKClient): The py42 sdk. - profile (Code42Profile): The profile under which to execute this command. - output_logger (Logger): The logger specified by which subcommand you use. For example, - print: uses a logger that streams to stdout. - write-to: uses a logger that logs to a file. - send-to: uses a logger that sends logs to a server. - args: Command line args used to build up alert query filters. - """ - store = AlertCursorStore(profile.name) if args.use_checkpoint else None - handlers = create_handlers(sdk, AlertExtractor, output_logger, store, args.use_checkpoint) - extractor = AlertExtractor(sdk, handlers) - if args.advanced_query: - extractor.extract_advanced(args.advanced_query) - else: - verify_begin_date_requirements(args, store) - _verify_alert_state(args.state) - _verify_alert_severity(args.severity) - filters = _create_alert_filters(args) - extractor.extract(*filters) - if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: - logger.print_info(u"No results found\n") - - -def _verify_alert_state(alert_state): - options = list(enums.AlertState()) - if alert_state and alert_state not in options: - logger.print_and_log_error( - u"'{0}' is not a valid alert state, options are {1}.".format(alert_state, options) - ) - exit(1) - - -def _verify_alert_severity(severity): - if severity is None: - return - options = list(enums.AlertSeverity()) - for s in severity: - if s not in options: - logger.print_and_log_error( - u"'{0}' is not a valid alert severity, options are {1}".format(s, options) - ) - exit(1) - - -def _create_alert_filters(args): - filters = [] - alert_timestamp_filter = create_time_range_filter(DateObserved, args.begin, args.end) - not alert_timestamp_filter or filters.append(alert_timestamp_filter) - not args.actor or filters.append(Actor.is_in(args.actor)) - not args.actor_contains or [filters.append(Actor.contains(arg)) for arg in args.actor_contains] - not args.exclude_actor or filters.append(Actor.not_in(args.exclude_actor)) - not args.exclude_actor_contains or [ - filters.append(Actor.not_contains(arg)) for arg in args.exclude_actor_contains - ] - not args.rule_name or filters.append(RuleName.is_in(args.rule_name)) - not args.exclude_rule_name or filters.append(RuleName.not_in(args.exclude_rule_name)) - not args.rule_id or filters.append(RuleId.is_in(args.rule_id)) - not args.exclude_rule_id or filters.append(RuleId.not_in(args.exclude_rule_id)) - not args.rule_type or filters.append(RuleType.is_in(args.rule_type)) - not args.exclude_rule_type or filters.append(RuleType.not_in(args.exclude_rule_type)) - not args.description or filters.append(Description.contains(args.description)) - not args.severity or filters.append(Severity.is_in(args.severity)) - not args.state or filters.append(AlertState.eq(args.state)) - return filters diff --git a/src/code42cli/cmds/alerts/main.py b/src/code42cli/cmds/alerts/main.py deleted file mode 100644 index 01856543d..000000000 --- a/src/code42cli/cmds/alerts/main.py +++ /dev/null @@ -1,208 +0,0 @@ -from code42cli.args import ArgConfig -from code42cli.commands import Command, SubcommandLoader -from code42cli.parser import exit_if_mutually_exclusive_args_used_together -from code42cli.cmds.alerts.extraction import extract -from code42cli.cmds.search_shared import args, logger_factory -from code42cli.cmds.search_shared.enums import ( - AlertFilterArguments, - AlertState, - AlertSeverity, - ServerProtocol, - RuleType, -) -from code42cli.cmds.search_shared.cursor_store import AlertCursorStore -from code42cli.cmds.search_shared.args import create_incompatible_search_args, SEARCH_FOR_ALERTS - - -class MainAlertsSubcommandLoader(SubcommandLoader): - PRINT = u"print" - WRITE_TO = u"write-to" - SEND_TO = u"send-to" - CLEAR_CHECKPOINT = u"clear-checkpoint" - - def load_commands(self): - """Sets up the `alerts` subcommand with all of its subcommands.""" - usage_prefix = u"code42 alerts" - - print_func = Command( - self.PRINT, - u"Print alerts to stdout", - u"{} {}".format(usage_prefix, u"print "), - handler=print_out, - arg_customizer=_load_search_args, - use_single_arg_obj=True, - ) - - write = Command( - self.WRITE_TO, - u"Write alerts to the file with the given name.", - u"{} {}".format(usage_prefix, u"write-to "), - handler=write_to, - arg_customizer=_load_write_to_args, - use_single_arg_obj=True, - ) - - send = Command( - self.SEND_TO, - u"Send alerts to the given server address.", - u"{} {}".format(usage_prefix, u"send-to "), - handler=send_to, - arg_customizer=_load_send_to_args, - use_single_arg_obj=True, - ) - - clear = Command( - self.CLEAR_CHECKPOINT, - u"Remove the saved alert checkpoint from 'use-checkpoint' (-c) mode.", - u"{} {}".format(usage_prefix, u"clear-checkpoint "), - handler=clear_checkpoint, - ) - - return [print_func, write, send, clear] - - -def clear_checkpoint(sdk, profile, cursor_name): - """Removes the stored checkpoint that keeps track of the last alert retrieved for the given profile.. - To use, run `code42 alerts clear-checkpoint`. - This affects `use-checkpoint` mode by resetting the checkpoint, causing it to behave like it has never been run before. - """ - AlertCursorStore(profile.name).delete(cursor_name) - - -def _validate_args(args): - if args.advanced_query: - incompatible_search_args_dict = create_incompatible_search_args(SEARCH_FOR_ALERTS) - incompatible_search_args_list = list(incompatible_search_args_dict.keys()) - invalid_args = incompatible_search_args_list + list(AlertFilterArguments()) - exit_if_mutually_exclusive_args_used_together(args, invalid_args) - - -def print_out(sdk, profile, args): - """Activates 'print' command. It gets alerts and prints them to stdout.""" - _validate_args(args) - logger = logger_factory.get_logger_for_stdout(args.format) - extract(sdk, profile, logger, args) - - -def write_to(sdk, profile, args): - """Activates 'write-to' command. It gets alerts and writes them to the given file.""" - _validate_args(args) - logger = logger_factory.get_logger_for_file(args.output_file, args.format) - extract(sdk, profile, logger, args) - - -def send_to(sdk, profile, args): - """Activates 'send-to' command. It getsalerts and logs them to the given server.""" - _validate_args(args) - logger = logger_factory.get_logger_for_server(args.server, args.protocol, args.format) - extract(sdk, profile, logger, args) - - -def _load_write_to_args(arg_collection): - output_file = ArgConfig(u"output_file", help=u"The name of the local file to send output to.") - arg_collection.append(u"output_file", output_file) - _load_search_args(arg_collection) - - -def _load_send_to_args(arg_collection): - send_to_args = { - u"server": ArgConfig(u"server", help=u"The server address to send output to."), - u"protocol": ArgConfig( - u"-p", - u"--protocol", - choices=ServerProtocol(), - default=ServerProtocol.UDP, - help=u"Protocol used to send logs to server.", - ), - } - - arg_collection.extend(send_to_args) - _load_search_args(arg_collection) - - -def _load_search_args(arg_collection): - filter_args = { - AlertFilterArguments.SEVERITY: ArgConfig( - u"--{}".format(AlertFilterArguments.SEVERITY), - nargs=u"+", - help=u"Filter alerts by severity. Defaults to returning all severities. Available choices={0}.".format( - list(AlertSeverity()) - ), - ), - AlertFilterArguments.STATE: ArgConfig( - u"--{}".format(AlertFilterArguments.STATE), - help=u"Filter alerts by state. Defaults to returning all states. Available choices={0}.".format( - list(AlertState()) - ), - ), - AlertFilterArguments.ACTOR: ArgConfig( - u"--{}".format(AlertFilterArguments.ACTOR.replace("_", "-")), - metavar=u"ACTOR", - help=u"Filter alerts by including the given actor(s) who triggered the alert. Args must match actor username exactly.", - nargs=u"+", - ), - AlertFilterArguments.ACTOR_CONTAINS: ArgConfig( - u"--{}".format(AlertFilterArguments.ACTOR_CONTAINS.replace("_", "-")), - metavar=u"ACTOR", - help=u"Filter alerts by including actor(s) whose username contains the given string.", - nargs=u"+", - ), - AlertFilterArguments.EXCLUDE_ACTOR: ArgConfig( - u"--{}".format(AlertFilterArguments.EXCLUDE_ACTOR.replace("_", "-")), - metavar=u"ACTOR", - help=u"Filter alerts by excluding the given actor(s) who triggered the alert. Args must match actor username exactly.", - nargs=u"+", - ), - AlertFilterArguments.EXCLUDE_ACTOR_CONTAINS: ArgConfig( - u"--{}".format(AlertFilterArguments.EXCLUDE_ACTOR_CONTAINS.replace("_", "-")), - metavar=u"ACTOR", - help=u"Filter alerts by excluding actor(s) whose username contains the given string.", - nargs=u"+", - ), - AlertFilterArguments.RULE_NAME: ArgConfig( - u"--{}".format(AlertFilterArguments.RULE_NAME.replace("_", "-")), - metavar=u"RULE_NAME", - help=u"Filter alerts by including the given rule name(s).", - nargs=u"+", - ), - AlertFilterArguments.EXCLUDE_RULE_NAME: ArgConfig( - u"--{}".format(AlertFilterArguments.EXCLUDE_RULE_NAME.replace("_", "-")), - metavar=u"RULE_NAME", - help=u"Filter alerts by excluding the given rule name(s).", - nargs=u"+", - ), - AlertFilterArguments.RULE_ID: ArgConfig( - u"--{}".format(AlertFilterArguments.RULE_ID.replace("_", "-")), - metavar=u"RULE_ID", - help=u"Filter alerts by including the given rule id(s).", - nargs=u"+", - ), - AlertFilterArguments.EXCLUDE_RULE_ID: ArgConfig( - u"--{}".format(AlertFilterArguments.EXCLUDE_RULE_ID.replace("_", "-")), - metavar=u"RULE_ID", - help=u"Filter alerts by excluding the given rule id(s).", - nargs=u"+", - ), - AlertFilterArguments.RULE_TYPE: ArgConfig( - u"--{}".format(AlertFilterArguments.RULE_TYPE.replace("_", "-")), - metavar=u"RULE_TYPE", - help=u"Filter alerts by including the given rule type(s). Available choices={0}.".format( - list(RuleType()) - ), - nargs=u"+", - ), - AlertFilterArguments.EXCLUDE_RULE_TYPE: ArgConfig( - u"--{}".format(AlertFilterArguments.EXCLUDE_RULE_TYPE.replace("_", "-")), - metavar=u"RULE_TYPE", - help=u"Filter alerts by excluding the given rule type(s). Available choices={0}.".format( - list(RuleType()) - ), - nargs=u"+", - ), - AlertFilterArguments.DESCRIPTION: ArgConfig( - u"--{}".format(AlertFilterArguments.DESCRIPTION), - help=u"Filter alerts by description. Does fuzzy search by default.", - ), - } - search_args = args.create_search_args(search_for=SEARCH_FOR_ALERTS, filter_args=filter_args) - arg_collection.extend(search_args) diff --git a/src/code42cli/cmds/alerts/rules/__init__.py b/src/code42cli/cmds/alerts/rules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/code42cli/cmds/alerts/rules/commands.py b/src/code42cli/cmds/alerts/rules/commands.py deleted file mode 100644 index 43d1c97d6..000000000 --- a/src/code42cli/cmds/alerts/rules/commands.py +++ /dev/null @@ -1,168 +0,0 @@ -import os - -from code42cli.commands import Command -from code42cli import MAIN_COMMAND -from code42cli.commands import Command, SubcommandLoader -from code42cli.bulk import generate_template, BulkCommandType -from code42cli.cmds.alerts.rules.user_rule import ( - add_user, - remove_user, - get_rules, - add_bulk_users, - remove_bulk_users, - show_rule, -) - - -def _customize_add_arguments(argument_collection): - rule_id = argument_collection.arg_configs[u"rule_id"] - rule_id.set_help(u"Observer ID of the rule to be updated.") - username = argument_collection.arg_configs[u"username"] - username.add_short_option_name("-u") - username.set_help(u"The username of the user to add to the alert rule.") - - -def _customize_remove_arguments(argument_collection): - rule_id = argument_collection.arg_configs[u"rule_id"] - rule_id.set_help(u"Observer ID of the rule to be updated.") - username = argument_collection.arg_configs[u"username"] - username.add_short_option_name("-u") - username.set_help(u"The username of the user to remove from the alert rule.") - - -def _customize_list_arguments(argument_collection): - rule_id = argument_collection.arg_configs[u"rule_id"] - rule_id.set_help(u"Observer ID of the rule.") - - -def _customize_bulk_add_arguments(argument_collection): - _customize_bulk_arguments(argument_collection, u"adding") - - -def _customize_bulk_remove_arguments(argument_collection): - _customize_bulk_arguments(argument_collection, u"removing") - - -def _customize_bulk_arguments(argument_collection, action): - file_name = argument_collection.arg_configs[u"file_name"] - file_name.set_help( - u"The path to the csv file with columns 'rule_id,username' " - u"for bulk {} users to the alert rule.".format(action) - ) - - -def _generate_template_file(cmd, path=None): - """Generates a template file a user would need to fill-in for bulk operating. - - Args: - cmd (str or unicode): An option from the `BulkCommandType` enum specifying which type of - file to generate. - path (str or unicode, optional): A path to put the file after it's generated. If None, will - use the current working directory. Defaults to None. - """ - handler = None - filename = u"alert_rule.csv" - if cmd == BulkCommandType.ADD: - handler = add_user - filename = u"add_users_to_{}".format(filename) - elif cmd == BulkCommandType.REMOVE: - handler = remove_user - filename = u"remove_users_from_{}".format(filename) - if not path: - path = os.path.join(os.getcwd(), filename) - generate_template(handler, path) - - -def _load_bulk_generate_template_description(argument_collection): - cmd_type = argument_collection.arg_configs[u"cmd"] - cmd_type.set_help(u"The type of command the template will be used for.") - cmd_type.set_choices(BulkCommandType()) - - -class AlertRulesBulkSubcommandLoader(SubcommandLoader): - GENERATE_TEMPLATE = u"generate-template" - ADD = u"add" - REMOVE = u"remove" - - def load_commands(self): - usage_prefix = u"{} alert-rules bulk".format(MAIN_COMMAND) - - generate_template_cmd = Command( - self.GENERATE_TEMPLATE, - u"Generate the necessary csv template for bulk actions.", - u"{} generate-template ".format(usage_prefix), - handler=_generate_template_file, - arg_customizer=_load_bulk_generate_template_description, - ) - - bulk_add = Command( - self.ADD, - u"Add users to alert rules. " u"CSV file format: `rule_id,username`.", - u"{} add ".format(usage_prefix), - handler=add_bulk_users, - arg_customizer=_customize_bulk_add_arguments, - ) - - bulk_remove = Command( - self.REMOVE, - u"Remove users from alert rules. " u"CSV file format: `rule_id,username`.", - u"{} remove ".format(usage_prefix), - handler=remove_bulk_users, - arg_customizer=_customize_bulk_remove_arguments, - ) - - return [generate_template_cmd, bulk_add, bulk_remove] - - -class AlertRulesSubcommandLoader(SubcommandLoader): - ADD_USER = u"add-user" - REMOVE_USER = u"remove-user" - LIST = u"list" - SHOW = u"show" - BULK = u"bulk" - - def __init__(self, root_command_name): - super(AlertRulesSubcommandLoader, self).__init__(root_command_name) - self._bulk_subcommand_loader = AlertRulesBulkSubcommandLoader(self.BULK) - - def load_commands(self): - usage_prefix = u"code42 alert-rules" - - add = Command( - self.ADD_USER, - u"Add a user to an alert rule.", - u"{} add-user --rule-id --username ".format(usage_prefix), - handler=add_user, - arg_customizer=_customize_add_arguments, - ) - - remove = Command( - self.REMOVE_USER, - u"Remove a user from an alert rule.", - u"{} remove-user --rule-id --username ".format(usage_prefix), - handler=remove_user, - arg_customizer=_customize_remove_arguments, - ) - - list_rules = Command( - self.LIST, - u"Fetch existing alert rules.", - u"{} list".format(usage_prefix), - handler=get_rules, - ) - - show = Command( - self.SHOW, - u"Print out detailed alert rule criteria.", - u"{} show ".format(usage_prefix), - handler=show_rule, - arg_customizer=_customize_list_arguments, - ) - - bulk = Command( - self.BULK, - u"Tools for executing bulk commands.", - subcommand_loader=self._bulk_subcommand_loader, - ) - - return [add, remove, list_rules, show, bulk] diff --git a/src/code42cli/cmds/alerts/rules/enums.py b/src/code42cli/cmds/alerts/rules/enums.py deleted file mode 100644 index 593dd0ef5..000000000 --- a/src/code42cli/cmds/alerts/rules/enums.py +++ /dev/null @@ -1,4 +0,0 @@ -class AlertRuleTypes(object): - EXFILTRATION = u"FED_ENDPOINT_EXFILTRATION" - CLOUD_SHARE = u"FED_CLOUD_SHARE_PERMISSIONS" - FILE_TYPE_MISMATCH = u"FED_FILE_TYPE_MISMATCH" diff --git a/src/code42cli/cmds/alerts/rules/user_rule.py b/src/code42cli/cmds/alerts/rules/user_rule.py deleted file mode 100644 index a84d68bc9..000000000 --- a/src/code42cli/cmds/alerts/rules/user_rule.py +++ /dev/null @@ -1,100 +0,0 @@ -from collections import OrderedDict - -from py42.exceptions import Py42InternalServerError -from py42.util import format_json - - -from code42cli.errors import InvalidRuleTypeError -from code42cli.util import format_to_table, find_format_width, get_user_id -from code42cli.bulk import run_bulk_process -from code42cli.file_readers import create_csv_reader -from code42cli.logger import get_main_cli_logger -from code42cli.cmds.alerts.rules.enums import AlertRuleTypes - - -_HEADER_KEYS_MAP = OrderedDict() -_HEADER_KEYS_MAP[u"observerRuleId"] = u"RuleId" -_HEADER_KEYS_MAP[u"name"] = u"Name" -_HEADER_KEYS_MAP[u"severity"] = u"Severity" -_HEADER_KEYS_MAP[u"type"] = u"Type" -_HEADER_KEYS_MAP[u"ruleSource"] = u"Source" -_HEADER_KEYS_MAP[u"isEnabled"] = u"Enabled" - - -def add_user(sdk, profile, rule_id, username): - user_id = get_user_id(sdk, username) - rules = _get_rule_metadata(sdk, rule_id) - try: - if rules: - sdk.alerts.rules.add_user(rule_id, user_id) - except Py42InternalServerError as e: - _check_if_system_rule(sdk, rules) - raise - - -def remove_user(sdk, profile, rule_id, username): - user_id = get_user_id(sdk, username) - rules = _get_rule_metadata(sdk, rule_id) - try: - if rules: - sdk.alerts.rules.remove_user(rule_id, user_id) - except Py42InternalServerError as e: - _check_if_system_rule(sdk, rules) - raise - - -def _get_all_rules_metadata(sdk): - rules_generator = sdk.alerts.rules.get_all() - selected_rules = [rule for rules in rules_generator for rule in rules[u"ruleMetadata"]] - return _handle_rules_results(sdk, selected_rules) - - -def _get_rule_metadata(sdk, rule_id): - rules = sdk.alerts.rules.get_by_observer_id(rule_id)[u"ruleMetadata"] - return _handle_rules_results(sdk, rules, rule_id) - - -def _handle_rules_results(sdk, rules, rule_id=None): - id_msg = u"with RuleId {} ".format(rule_id) if rule_id else u"" - msg = u"No alert rules {0}found.".format(id_msg) - if not rules: - get_main_cli_logger().print_and_log_info(msg) - return rules - - -def _check_if_system_rule(sdk, rules): - if rules and rules[0][u"isSystem"]: - raise InvalidRuleTypeError(rules[0][u"observerRuleId"], rules[0][u"ruleSource"]) - - -def get_rules(sdk, profile): - selected_rules = _get_all_rules_metadata(sdk) - if selected_rules: - rows, column_size = find_format_width(selected_rules, _HEADER_KEYS_MAP) - format_to_table(rows, column_size) - - -def add_bulk_users(sdk, profile, file_name): - reader = create_csv_reader(file_name) - run_bulk_process(lambda rule_id, username: add_user(sdk, profile, rule_id, username), reader) - - -def remove_bulk_users(sdk, profile, file_name): - reader = create_csv_reader(file_name) - run_bulk_process(lambda rule_id, username: remove_user(sdk, profile, rule_id, username), reader) - - -def show_rule(sdk, profile, rule_id): - selected_rule = _get_rule_metadata(sdk, rule_id) - rule_detail = None - if selected_rule: - rule_type = selected_rule[0][u"type"] - if rule_type == AlertRuleTypes.EXFILTRATION: - rule_detail = sdk.alerts.rules.exfiltration.get(rule_id) - elif rule_type == AlertRuleTypes.CLOUD_SHARE: - rule_detail = sdk.alerts.rules.cloudshare.get(rule_id) - elif rule_type == AlertRuleTypes.FILE_TYPE_MISMATCH: - rule_detail = sdk.alerts.rules.filetypemismatch.get(rule_id) - if rule_detail: - logger = get_main_cli_logger() - logger.print_info(format_json(rule_detail.text)) diff --git a/src/code42cli/cmds/alerts/util.py b/src/code42cli/cmds/alerts/util.py deleted file mode 100644 index 048fdabab..000000000 --- a/src/code42cli/cmds/alerts/util.py +++ /dev/null @@ -1,14 +0,0 @@ -from code42cli.compat import range - -_BATCH_SIZE = 100 - - -def get_alert_details(sdk, alert_summary_list): - alert_ids = [alert[u"id"] for alert in alert_summary_list] - batches = [alert_ids[i : i + _BATCH_SIZE] for i in range(0, len(alert_ids), _BATCH_SIZE)] - results = [] - for batch in batches: - r = sdk.alerts.get_details(batch) - results.extend(r[u"alerts"]) - results = sorted(results, key=lambda x: x[u"createdAt"], reverse=True) - return results diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py new file mode 100644 index 000000000..e6a859231 --- /dev/null +++ b/src/code42cli/cmds/departing_employee.py @@ -0,0 +1,100 @@ +import click +from py42.exceptions import Py42BadRequestError + +from code42cli.bulk import generate_template_cmd_factory, run_bulk_process +from code42cli.cmds.detectionlists import update_user, try_handle_user_already_added_error +from code42cli.cmds.detectionlists.options import ( + username_arg, + cloud_alias_option, + notes_option, +) +from code42cli.cmds.shared import get_user_id +from code42cli.file_readers import read_csv_arg, read_flat_file_arg +from code42cli.options import sdk_options, OrderedGroup + + +@click.group(cls=OrderedGroup) +@sdk_options +def departing_employee(state): + """For adding and removing employees from the departing employee detection list.""" + pass + + +@departing_employee.command() +@username_arg +@click.option("--departure-date", help="The date the employee is departing. Format: yyyy-MM-dd.") +@cloud_alias_option +@notes_option +@sdk_options +def add(state, username, cloud_alias, departure_date, notes): + """Add a user to the departing-employee detection list.""" + _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) + + +@departing_employee.command() +@username_arg +@sdk_options +def remove(state, username): + """Remove a user from the departing-employee detection list.""" + _remove_departing_employee(state.sdk, username) + + +@departing_employee.group(cls=OrderedGroup) +@sdk_options +def bulk(state): + """Tools for executing bulk departing employee actions.""" + pass + + +DEPARTING_EMPLOYEE_CSV_HEADERS = ["username", "cloud_alias", "departure_date", "notes"] + +departing_employee_generate_template = generate_template_cmd_factory( + group_name="departing_employee", + commands_dict={"add": DEPARTING_EMPLOYEE_CSV_HEADERS, "remove": "username"}, +) +bulk.add_command(departing_employee_generate_template) + + +@bulk.command( + help="Bulk add users to the departing-employee detection list using a csv file with " + "format: {}".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)) +) +@read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) +@sdk_options +def add(state, csv_rows): + row_handler = lambda username, cloud_alias, departure_date, notes: _add_departing_employee( + state.sdk, username, cloud_alias, departure_date, notes + ) + run_bulk_process( + row_handler, csv_rows, progress_label="Adding users to departing employee detection list:" + ) + + +@bulk.command( + help="Bulk remove users from the departing-employee detection list using a newline separated " + "file of usernames." +) +@read_flat_file_arg +@sdk_options +def remove(state, file_rows): + row_handler = lambda username: _remove_departing_employee(state.sdk, username) + run_bulk_process( + row_handler, + file_rows, + progress_label="Removing users from departing employee detection list:", + ) + + +def _add_departing_employee(sdk, username, cloud_alias, departure_date, notes): + user_id = get_user_id(sdk, username) + try: + sdk.detectionlists.departing_employee.add(user_id, departure_date) + update_user(sdk, username, cloud_alias=cloud_alias, notes=notes) + except Py42BadRequestError as err: + try_handle_user_already_added_error(err, username, "departing-employee list") + raise + + +def _remove_departing_employee(sdk, username): + user_id = get_user_id(sdk, username) + sdk.detectionlists.departing_employee.remove(user_id) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index a80d6cff4..ab6658eee 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,214 +1,10 @@ -from py42.exceptions import Py42BadRequestError +from code42cli.cmds.shared import get_user_id +from code42cli.errors import UserAlreadyAddedError -from code42cli.cmds.detectionlists.commands import DetectionListSubcommandLoader -from code42cli.bulk import generate_template, run_bulk_process -from code42cli.file_readers import create_csv_reader, create_flat_file_reader -from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError -from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags -from code42cli.cmds.detectionlists.bulk import BulkDetectionList, BulkHighRiskEmployee -from code42cli.util import get_user_id - -def try_handle_user_already_added_error(bad_request_err, username_tried_adding, list_name): - if _error_is_user_already_added(bad_request_err.response.text): - raise UserAlreadyAddedError(username_tried_adding, list_name) - return False - - -def _error_is_user_already_added(bad_request_error_text): - return u"User already on list" in bad_request_error_text - - -class DetectionListHandlers(object): - """Handlers DTO for passing in specific detection list functions. - - Args: - add (callable): A function that adds an employee to the list. - remove (callable): A function that removes an employee from the list. - load_add (callable): A function that loads the add-related `ArgConfig`s. - """ - - def __init__(self, add=None, remove=None, load_add=None): - self.add_employee = add - self.remove_employee = remove - self.load_add_description = load_add - - def add_handler(self, attr_name, handler): - self.__setattr__(attr_name, handler) - - -class DetectionList(object): - """An object representing a Code42 detection list. Use this class by passing in handlers for - adding and removing employees. This class will handle the bulk-related commands and some - search_shared help texts. - - Args: - list_name (str or unicode): An option from the DetectionLists enum. For convenience, use one of the - given `classmethods`. - handlers (DetectionListHandlers): A DTO containing implementations for adding / removing - users from specific lists. - cmd_factory (DetectionListSubcommandLoader): A factory that creates detection list commands. - """ - - def __init__(self, list_name, handlers, subcommand_loader=None): - self.name = list_name - self.handlers = handlers - self.subcommand_loader = subcommand_loader or DetectionListSubcommandLoader(list_name) - self.bulk_subcommand_loader = self.subcommand_loader.bulk_subcommand_loader - self.bulk_subcommand_loader.load_commands = lambda: self._load_bulk_subcommands - - @classmethod - def create_high_risk_employee_list(cls, handlers): - """Creates a high risk employee detection list. - - Args: - handlers (DetectionListHandlers): A DTO containing implementations for adding / - removing users from specific lists. - - Returns: - DetectionList: A high risk employee detection list. - """ - return cls(DetectionLists.HIGH_RISK_EMPLOYEE, handlers) - - @classmethod - def create_departing_employee_list(cls, handlers): - """Creates a departing employee detection list. - - Args: - handlers (DetectionListHandlers): A DTO containing implementations for adding / - removing users from specific lists. - - Returns: - DetectionList: A departing employee detection list. - """ - return cls(DetectionLists.DEPARTING_EMPLOYEE, handlers) - - def load_subcommands(self): - """Loads high risk employee related subcommands""" - bulk = self.subcommand_loader.create_bulk_command() - bulk.subcommand_loader.load_commands = lambda: self._load_bulk_subcommands() - add = self.subcommand_loader.create_add_command( - self.handlers.add_employee, self.handlers.load_add_description - ) - remove = self.subcommand_loader.create_remove_command( - self.handlers.remove_employee, load_username_description - ) - return [bulk, add, remove] - - def _load_bulk_subcommands(self): - add = self.bulk_subcommand_loader.create_bulk_add_command( - self.bulk_add_employees, self.handlers.add_employee - ) - remove = self.bulk_subcommand_loader.create_bulk_remove_command(self.bulk_remove_employees) - commands = [add, remove] - - if self.name == DetectionLists.HIGH_RISK_EMPLOYEE: - commands.extend(self._get_risk_tags_bulk_subcommands()) - else: - generate_template_cmd = self.bulk_subcommand_loader.create_bulk_generate_template_command( - self.generate_template_file - ) - commands.append(generate_template_cmd) - return commands - - def _get_risk_tags_bulk_subcommands(self): - bulk_add_risk_tags = self.bulk_subcommand_loader.create_bulk_add_risk_tags_command( - self.bulk_add_risk_tags, add_risk_tags - ) - bulk_remove_risk_tags = self.bulk_subcommand_loader.create_bulk_remove_risk_tags_command( - self.bulk_remove_risk_tags, remove_risk_tags - ) - - self.handlers.add_handler(u"add_risk_tags", add_risk_tags) - self.handlers.add_handler(u"remove_risk_tags", remove_risk_tags) - generate_template_cmd = self.bulk_subcommand_loader.create_hre_bulk_generate_template_command( - self.generate_template_file - ) - return [bulk_add_risk_tags, bulk_remove_risk_tags, generate_template_cmd] - - def generate_template_file(self, cmd, path=None): - """Generates a template file a user would need to fill-in for bulk operating on the - detection list. - - Args: - cmd (str or unicode): An option from the `BulkCommandType` enum specifying which type of file to - generate. - path (str or unicode, optional): A path to put the file after it's generated. If None, will use - the current working directory. Defaults to None. - """ - - if self.name == DetectionLists.HIGH_RISK_EMPLOYEE: - detection_list = BulkHighRiskEmployee() - else: - detection_list = BulkDetectionList() - handler = detection_list.get_handler(self.handlers, cmd) - generate_template(handler, path) - - def bulk_add_employees(self, sdk, profile, filename): - """Takes a csv file with each row representing an employee and adds them all to a - detection list in a bulk fashion. - - Args: - sdk (py42.sdk.SDKClient): The py42 sdk. - profile (Code42Profile): The profile under which to execute this command. - filename (str or unicode): The path to the csv file containing rows of users. - """ - reader = create_csv_reader(filename) - run_bulk_process(lambda **kwargs: self._add_employee(sdk, profile, **kwargs), reader) - - def bulk_remove_employees(self, sdk, profile, users_file): - """Takes a flat file with each row containing a username and removes them all from the - detection list in a bulk fashion. - - Args: - sdk (py42.sdk.SDKClient): The py42 sdk. - profile (Code42Profile): The profile under which to execute this command. - users_file (str or unicode): The path to the file containing rows of user names. - """ - reader = create_flat_file_reader(users_file) - run_bulk_process( - lambda *args, **kwargs: self._remove_employee(sdk, profile, *args, **kwargs), reader - ) - - def _add_employee(self, sdk, profile, **kwargs): - self.handlers.add_employee(sdk, profile, **kwargs) - - def _remove_employee(self, sdk, profile, *args, **kwargs): - self.handlers.remove_employee(sdk, profile, *args, **kwargs) - - def bulk_add_risk_tags(self, sdk, profile, filename): - reader = create_csv_reader(filename) - run_bulk_process(lambda **kwargs: add_risk_tags(sdk, profile, **kwargs), reader) - - def bulk_remove_risk_tags(self, sdk, profile, filename): - reader = create_csv_reader(filename) - run_bulk_process(lambda **kwargs: remove_risk_tags(sdk, profile, **kwargs), reader) - - -def load_username_description(argument_collection): - """Loads the arg descriptions for the `username` CLI parameter.""" - username = argument_collection.arg_configs[DetectionListUserKeys.USERNAME] - username.set_help(u"A Code42 username for an employee.") - - -def load_user_descriptions(argument_collection): - """Loads the arg descriptions related to updating fields about a detection list user, such as - notes or a cloud alias. - - Args: - argument_collection (ArgConfigCollection): The arg configs off the command that needs its - user descriptions loaded. - """ - load_username_description(argument_collection) - cloud_alias = argument_collection.arg_configs[DetectionListUserKeys.CLOUD_ALIAS] - notes = argument_collection.arg_configs[DetectionListUserKeys.NOTES] - cloud_alias.set_help(u"An alternative email address for another cloud service.") - notes.set_help(u"Notes about the employee.") - - -def update_user(sdk, user_id, cloud_alias=None, risk_tag=None, notes=None): +def update_user(sdk, username, cloud_alias=None, risk_tag=None, notes=None): """Updates a detection list user. - + Args: sdk (py42.sdk.SDKClient): py42 sdk. user_id (str or unicode): The ID of the user to update. This is their `userUid` found from @@ -217,52 +13,40 @@ def update_user(sdk, user_id, cloud_alias=None, risk_tag=None, notes=None): risk_tag (iter[str or unicode]): A list of risk tags associated with user. notes (str or unicode): Notes about the user. """ + user_id = get_user_id(sdk, username) if cloud_alias: sdk.detectionlists.add_user_cloud_alias(user_id, cloud_alias) if risk_tag: - try_add_risk_tags(sdk, user_id, risk_tag) + add_risk_tags(sdk, username, risk_tag) if notes: sdk.detectionlists.update_user_notes(user_id, notes) -def try_add_risk_tags(sdk, user_id, risk_tag): - _try_add_or_remove_risk_tags(user_id, risk_tag, sdk.detectionlists.add_user_risk_tags) +def add_risk_tags(sdk, username, risk_tag): + risk_tag = handle_list_args(risk_tag) + user_id = get_user_id(sdk, username) + sdk.detectionlists.add_user_risk_tags(user_id, risk_tag) -def try_remove_risk_tags(sdk, user_id, risk_tag): - _try_add_or_remove_risk_tags(user_id, risk_tag, sdk.detectionlists.remove_user_risk_tags) +def remove_risk_tags(sdk, username, risk_tag): + risk_tag = handle_list_args(risk_tag) + user_id = get_user_id(sdk, username) + sdk.detectionlists.remove_user_risk_tags(user_id, risk_tag) -def _try_add_or_remove_risk_tags(user_id, risk_tag, func): - try: - func(user_id, risk_tag) - except Py42BadRequestError: - _try_handle_bad_risk_tag(risk_tag) - raise +def try_handle_user_already_added_error(bad_request_err, username_tried_adding, list_name): + if _error_is_user_already_added(bad_request_err.response.text): + raise UserAlreadyAddedError(username_tried_adding, list_name) + return False -def _try_handle_bad_risk_tag(tags): - options = list(RiskTags()) - unknowns = [tag for tag in tags if tag not in options] if tags else None - if unknowns: - raise UnknownRiskTagError(unknowns) +def _error_is_user_already_added(bad_request_error_text): + return "User already on list" in bad_request_error_text def handle_list_args(list_arg): - """Converts str args to a list. Useful for `bulk` commands which don't use `argparse` but - instead pass in values from files, such as in the form "item1 item2".""" - if list_arg and not isinstance(list_arg, list): + """Converts str args to a list. Useful for `bulk` commands which don't use click's argument + parsing but instead pass in values from files, such as in the form "item1 item2".""" + if isinstance(list_arg, str): return list_arg.split() return list_arg - - -def add_risk_tags(sdk, profile, username, tag): - risk_tag = handle_list_args(tag) - user_id = get_user_id(sdk, username) - try_add_risk_tags(sdk, user_id, risk_tag) - - -def remove_risk_tags(sdk, profile, username, tag): - risk_tag = handle_list_args(tag) - user_id = get_user_id(sdk, username) - try_remove_risk_tags(sdk, user_id, risk_tag) diff --git a/src/code42cli/cmds/detectionlists/bulk.py b/src/code42cli/cmds/detectionlists/bulk.py deleted file mode 100644 index 3867d8b62..000000000 --- a/src/code42cli/cmds/detectionlists/bulk.py +++ /dev/null @@ -1,39 +0,0 @@ -from code42cli.bulk import BulkCommandType - - -class HighRiskBulkCommandType(BulkCommandType): - ADD_RISK_TAG = u"add-risk-tags" - REMOVE_RISK_TAG = u"remove-risk-tags" - - def __iter__(self): - parent_items = list(super(HighRiskBulkCommandType, self).__iter__()) - return iter([parent_items[0], parent_items[1], self.ADD_RISK_TAG, self.REMOVE_RISK_TAG]) - - -class BulkDetectionList(object): - def __init__(self): - self.type = BulkCommandType - - def get_handler(self, handlers, cmd): - handler = None - if cmd == self.type.ADD: - handler = handlers.add_employee - elif cmd == self.type.REMOVE: - handler = handlers.remove_employee - return handler - - -class BulkHighRiskEmployee(BulkDetectionList): - def __init__(self): - super(BulkHighRiskEmployee, self).__init__() - self.type = HighRiskBulkCommandType - - def get_handler(self, handlers, cmd): - handler = super(BulkHighRiskEmployee, self).get_handler(handlers, cmd) - if not handler: - if cmd == self.type.ADD_RISK_TAG: - handler = handlers.add_risk_tags - elif cmd == self.type.REMOVE_RISK_TAG: - handler = handlers.remove_risk_tags - - return handler diff --git a/src/code42cli/cmds/detectionlists/commands.py b/src/code42cli/cmds/detectionlists/commands.py deleted file mode 100644 index 077c7a504..000000000 --- a/src/code42cli/cmds/detectionlists/commands.py +++ /dev/null @@ -1,185 +0,0 @@ -import inspect - -from code42cli.bulk import BulkCommandType -from code42cli.commands import Command, SubcommandLoader -from code42cli.cmds.detectionlists.bulk import HighRiskBulkCommandType - - -def create_usage_prefix(detection_list_name): - return u"code42 {}".format(detection_list_name) - - -def create_bulk_usage_prefix(detection_list_name): - return u"{} bulk".format(create_usage_prefix(detection_list_name)) - - -def _load_bulk_generate_template_description(argument_collection): - cmd_type = argument_collection.arg_configs[u"cmd"] - cmd_type.set_help(u"The type of command the template with be used for.") - cmd_type.set_choices(BulkCommandType()) - - -class DetectionListSubcommandLoader(SubcommandLoader): - BULK = u"bulk" - ADD = BulkCommandType.ADD - REMOVE = BulkCommandType.REMOVE - _USAGE_SUFFIX = u" " - - def __init__(self, detection_list_name, bulk_subcommand_loader=None): - super(DetectionListSubcommandLoader, self).__init__(detection_list_name) - self._name = detection_list_name - self._usage_prefix = create_usage_prefix(detection_list_name) - self.bulk_subcommand_loader = bulk_subcommand_loader or DetectionListBulkSubcommandLoader( - self.BULK, detection_list_name - ) - - def create_bulk_command(self): - return Command( - self.BULK, - u"Tools for executing bulk {} commands.".format(self._name), - subcommand_loader=self.bulk_subcommand_loader, - ) - - def create_add_command(self, handler, arg_customizer): - return Command( - self.ADD, - u"Add a user to the {} detection list.".format(self._name), - u"{} {} {}".format(self._usage_prefix, BulkCommandType.ADD, self._USAGE_SUFFIX), - handler=handler, - arg_customizer=arg_customizer, - ) - - def create_remove_command(self, handler, arg_customizer): - return Command( - self.REMOVE, - u"Remove a user from the {} detection list.".format(self._name), - u"{} {} {}".format(self._usage_prefix, BulkCommandType.REMOVE, self._USAGE_SUFFIX), - handler=handler, - arg_customizer=arg_customizer, - ) - - -class DetectionListBulkSubcommandLoader(SubcommandLoader): - ADD = BulkCommandType.ADD - REMOVE = BulkCommandType.REMOVE - GENERATE_TEMPLATE = u"generate-template" - - def __init__(self, root_command_name, detection_list_name): - super(DetectionListBulkSubcommandLoader, self).__init__(root_command_name) - self._bulk_usage_prefix = create_bulk_usage_prefix(detection_list_name) - self._name = detection_list_name - - def create_bulk_generate_template_command(self, handler): - return Command( - self.GENERATE_TEMPLATE, - u"Generate the necessary csv template needed for bulk adding users.", - u"{} generate-template ".format(self._bulk_usage_prefix), - handler=handler, - arg_customizer=_load_bulk_generate_template_description, - ) - - def create_hre_bulk_generate_template_command(self, handler): - return Command( - u"generate-template", - u"Generate the necessary csv template for bulk actions.", - u"{} generate-template ".format(self._bulk_usage_prefix), - handler=handler, - arg_customizer=self._load_hre_bulk_generate_template_description, - ) - - def create_bulk_add_command(self, cmd_handler, row_handler): - file_format = _get_file_format(row_handler) - return Command( - self.ADD, - u"Add users to the {} detection list. CSV file format: `{}`.".format( - self._name, file_format - ), - u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.ADD), - handler=cmd_handler, - arg_customizer=self._load_bulk_add_description, - ) - - def create_bulk_remove_command(self, cmd_handler): - return Command( - self.REMOVE, - u"Remove users from the {} detection list. " - u"The file format is an end-line-delimited list of users.".format(self._name), - u"{} {} ".format(self._bulk_usage_prefix, BulkCommandType.REMOVE), - handler=cmd_handler, - arg_customizer=self._load_bulk_remove_description, - ) - - def create_bulk_add_risk_tags_command(self, cmd_handler, row_handler): - file_format = _get_file_format(row_handler) - return Command( - u"add-risk-tags", - u"Associates risk tags with a user in bulk. CSV file format: `{}`.".format(file_format), - u"{} {} ".format(self._bulk_usage_prefix, HighRiskBulkCommandType.ADD_RISK_TAG), - handler=cmd_handler, - arg_customizer=self._load_bulk_add_risk_tags_description, - ) - - def create_bulk_remove_risk_tags_command(self, cmd_handler, row_handler): - file_format = _get_file_format(row_handler) - return Command( - u"remove-risk-tags", - u"Disassociates risk tags from a user in bulk. CSV file format: `{}`.".format( - file_format - ), - u"{} {} ".format( - self._bulk_usage_prefix, HighRiskBulkCommandType.REMOVE_RISK_TAG - ), - handler=cmd_handler, - arg_customizer=self._load_bulk_remove_risk_tags_description, - ) - - @staticmethod - def _load_bulk_generate_template_description(argument_collection): - cmd_type = argument_collection.arg_configs[u"cmd"] - cmd_type.set_help(u"The type of command the template will be used for.") - cmd_type.set_choices(BulkCommandType()) - - @staticmethod - def _load_hre_bulk_generate_template_description(argument_collection): - cmd_type = argument_collection.arg_configs[u"cmd"] - cmd_type.set_help(u"The type of command the template will be used for.") - cmd_type.set_choices(HighRiskBulkCommandType()) - - def _load_bulk_add_description(self, argument_collection): - filename = argument_collection.arg_configs[u"filename"] - filename.set_help( - u"The path to the csv file for bulk adding users to the {} detection list.".format( - self._name - ) - ) - - def _load_bulk_remove_description(self, argument_collection): - users_file = argument_collection.arg_configs[u"users_file"] - users_file.set_help( - u"A file containing a line-separated list of users to remove form the {} detection list.".format( - self._name - ) - ) - - def _load_bulk_add_risk_tags_description(self, argument_collection): - filename = argument_collection.arg_configs[u"filename"] - filename.set_help( - u"A file containing a ',' separated username with space-separated tags to add " - u"to the {} detection list. " - u"e.g. test@email.com,tag1 tag2 tag3".format(self._name) - ) - - def _load_bulk_remove_risk_tags_description(self, argument_collection): - filename = argument_collection.arg_configs[u"filename"] - filename.set_help( - u"A file containing a ',' separated username with space-separated tags to remove " - u"from the {} detection list. " - u"e.g. test@email.com,tag1 tag2 tag3".format(self._name) - ) - - -def _get_file_format(row_handler): - args = inspect.getargspec(row_handler).args - args.remove(u"profile") - args.remove(u"sdk") - return u", ".join(args) diff --git a/src/code42cli/cmds/detectionlists/departing_employee.py b/src/code42cli/cmds/detectionlists/departing_employee.py deleted file mode 100644 index a552cb2b5..000000000 --- a/src/code42cli/cmds/detectionlists/departing_employee.py +++ /dev/null @@ -1,64 +0,0 @@ -from code42cli.cmds.detectionlists import ( - DetectionList, - DetectionListHandlers, - load_user_descriptions, - update_user, - try_handle_user_already_added_error, - DetectionListSubcommandLoader, -) -from code42cli.util import get_user_id -from code42cli.cmds.detectionlists.enums import DetectionLists - -from py42.exceptions import Py42BadRequestError - - -class DepartingEmployeeSubcommandLoader(DetectionListSubcommandLoader): - def __init__(self, root_command_name): - super(DepartingEmployeeSubcommandLoader, self).__init__(root_command_name) - handlers = _create_handlers() - self.detection_list = DetectionList.create_departing_employee_list(handlers) - self._cmd_loader = self.detection_list.subcommand_loader - - def load_commands(self): - return self.detection_list.load_subcommands() - - -def _create_handlers(): - return DetectionListHandlers( - add=add_departing_employee, remove=remove_departing_employee, load_add=_load_add_description - ) - - -def add_departing_employee( - sdk, profile, username, cloud_alias=None, departure_date=None, notes=None -): - """Adds an employee to the departing employee detection list. - - Args: - sdk (py42.sdk.SDKClient): py42. - profile (C42Profile): Your code42 profile. - username (str): The username of the employee to add. - cloud_alias (str): An alternative email address for another cloud service. - departure_date (str): The date the employee is departing in format `yyyy-MM-dd`. - notes: (str): Notes about the employee. - """ - user_id = get_user_id(sdk, username) - - try: - sdk.detectionlists.departing_employee.add(user_id, departure_date) - update_user(sdk, user_id, cloud_alias, notes=notes) - except Py42BadRequestError as err: - list_name = u"{} list".format(DetectionLists.DEPARTING_EMPLOYEE) - try_handle_user_already_added_error(err, username, list_name) - raise - - -def remove_departing_employee(sdk, profile, username): - user_id = get_user_id(sdk, username) - sdk.detectionlists.departing_employee.remove(user_id) - - -def _load_add_description(argument_collection): - load_user_descriptions(argument_collection) - departure_date = argument_collection.arg_configs[u"departure_date"] - departure_date.set_help(u"The date the employee is departing in format yyyy-MM-dd.") diff --git a/src/code42cli/cmds/detectionlists/enums.py b/src/code42cli/cmds/detectionlists/enums.py index 5f9887da7..45034c39d 100644 --- a/src/code42cli/cmds/detectionlists/enums.py +++ b/src/code42cli/cmds/detectionlists/enums.py @@ -1,23 +1,11 @@ -class DetectionLists(object): - DEPARTING_EMPLOYEE = u"departing-employee" - HIGH_RISK_EMPLOYEE = u"high-risk-employee" - - -class DetectionListUserKeys(object): - CLOUD_ALIAS = u"cloud_alias" - USERNAME = u"username" - NOTES = u"notes" - RISK_TAG = u"risk_tag" - - class RiskTags(object): - FLIGHT_RISK = u"FLIGHT_RISK" - HIGH_IMPACT_EMPLOYEE = u"HIGH_IMPACT_EMPLOYEE" - ELEVATED_ACCESS_PRIVILEGES = u"ELEVATED_ACCESS_PRIVILEGES" - PERFORMANCE_CONCERNS = u"PERFORMANCE_CONCERNS" - SUSPICIOUS_SYSTEM_ACTIVITY = u"SUSPICIOUS_SYSTEM_ACTIVITY" - POOR_SECURITY_PRACTICES = u"POOR_SECURITY_PRACTICES" - CONTRACT_EMPLOYEE = u"CONTRACT_EMPLOYEE" + FLIGHT_RISK = "FLIGHT_RISK" + HIGH_IMPACT_EMPLOYEE = "HIGH_IMPACT_EMPLOYEE" + ELEVATED_ACCESS_PRIVILEGES = "ELEVATED_ACCESS_PRIVILEGES" + PERFORMANCE_CONCERNS = "PERFORMANCE_CONCERNS" + SUSPICIOUS_SYSTEM_ACTIVITY = "SUSPICIOUS_SYSTEM_ACTIVITY" + POOR_SECURITY_PRACTICES = "POOR_SECURITY_PRACTICES" + CONTRACT_EMPLOYEE = "CONTRACT_EMPLOYEE" def __iter__(self): return iter( diff --git a/src/code42cli/cmds/detectionlists/high_risk_employee.py b/src/code42cli/cmds/detectionlists/high_risk_employee.py deleted file mode 100644 index d9da4bd40..000000000 --- a/src/code42cli/cmds/detectionlists/high_risk_employee.py +++ /dev/null @@ -1,110 +0,0 @@ -from py42.exceptions import Py42BadRequestError - -from code42cli.cmds.detectionlists import DetectionListSubcommandLoader -from code42cli.commands import Command -from code42cli.cmds.detectionlists import ( - DetectionList, - DetectionListHandlers, - load_user_descriptions, - update_user, - try_handle_user_already_added_error, - add_risk_tags, - remove_risk_tags, - load_username_description, - handle_list_args, -) -from code42cli.util import get_user_id -from code42cli.cmds.detectionlists.enums import DetectionLists, DetectionListUserKeys, RiskTags - - -class HighRiskEmployeeSubcommandLoader(DetectionListSubcommandLoader): - def __init__(self, root_command_name): - super(HighRiskEmployeeSubcommandLoader, self).__init__(root_command_name) - handlers = _create_handlers() - self.detection_list = DetectionList.create_high_risk_employee_list(handlers) - self._cmd_loader = self.detection_list.subcommand_loader - - def load_commands(self): - cmds = self.detection_list.load_subcommands() - cmds.extend( - [ - Command( - u"add-risk-tags", - u"Associates risk tags with a user.", - u"code42 high-risk-employee add-risk-tags --username --tag ", - handler=add_risk_tags, - arg_customizer=load_risk_tag_mgmt_descriptions, - ), - Command( - u"remove-risk-tags", - u"Disassociates risk tags from a user.", - u"code42 high-risk-employee remove-risk-tags --username --tag ", - handler=remove_risk_tags, - arg_customizer=load_risk_tag_mgmt_descriptions, - ), - ] - ) - return cmds - - -def _create_handlers(): - return DetectionListHandlers( - add=add_high_risk_employee, remove=remove_high_risk_employee, load_add=_load_add_description - ) - - -def add_high_risk_employee(sdk, profile, username, cloud_alias=None, risk_tag=None, notes=None): - """Adds an employee to the high risk employee detection list. - - Args: - sdk (py42.sdk.SDKClient): py42. - profile (C42Profile): Your code42 profile. - username (str): The username of the employee to add. - cloud_alias (str): An alternative email address for another cloud service. - risk_tag (iter[str]): Risk tags associated with the employee. - notes: (str): Notes about the employee. - """ - risk_tag = handle_list_args(risk_tag) - user_id = get_user_id(sdk, username) - - try: - sdk.detectionlists.high_risk_employee.add(user_id) - update_user(sdk, user_id, cloud_alias, risk_tag, notes) - except Py42BadRequestError as err: - list_name = u"{} list".format(DetectionLists.HIGH_RISK_EMPLOYEE) - try_handle_user_already_added_error(err, username, list_name) - raise - - -def remove_high_risk_employee(sdk, profile, username): - """Removes an employee from the high risk employee detection list. - - Args: - sdk (py42.sdk.SDKClient): py42. - profile (C42Profile): Your code42 profile. - username (str): The username of the employee to remove. - """ - user_id = get_user_id(sdk, username) - sdk.detectionlists.high_risk_employee.remove(user_id) - - -def _load_add_description(argument_collection): - load_user_descriptions(argument_collection) - load_risk_tag_description(argument_collection) - - -def load_risk_tag_description(argument_collection): - risk_tag = ( - argument_collection.arg_configs.get(DetectionListUserKeys.RISK_TAG) - or argument_collection.arg_configs[u"tag"] - ) - risk_tag.as_multi_val_param() - tags = u", ".join(list(RiskTags())) - risk_tag.set_help( - u"Risk tags associated with the employee. Options include: [{}].".format(tags) - ) - - -def load_risk_tag_mgmt_descriptions(argument_collection): - load_username_description(argument_collection) - load_risk_tag_description(argument_collection) diff --git a/src/code42cli/cmds/detectionlists/options.py b/src/code42cli/cmds/detectionlists/options.py new file mode 100644 index 000000000..252f2df01 --- /dev/null +++ b/src/code42cli/cmds/detectionlists/options.py @@ -0,0 +1,10 @@ +import click + +username_arg = click.argument("username") +cloud_alias_option = click.option( + "--cloud-alias", + help="If the employee has an email alias other than their Code42 username " + "that they use for cloud services such as Google Drive, OneDrive, or Box, " + "add and monitor the alias.", +) +notes_option = click.option("--notes", help="Notes about the employee.") diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py new file mode 100644 index 000000000..9e0dffeee --- /dev/null +++ b/src/code42cli/cmds/high_risk_employee.py @@ -0,0 +1,176 @@ +import click +from py42.exceptions import Py42BadRequestError + +from code42cli.bulk import run_bulk_process, generate_template_cmd_factory +from code42cli.cmds.detectionlists import ( + update_user, + add_risk_tags as _add_risk_tags, + remove_risk_tags as _remove_risk_tags, + try_handle_user_already_added_error, + handle_list_args, +) +from code42cli.cmds.detectionlists.enums import RiskTags +from code42cli.cmds.detectionlists.options import ( + cloud_alias_option, + notes_option, + username_arg, +) +from code42cli.cmds.shared import get_user_id +from code42cli.file_readers import read_csv_arg, read_flat_file_arg +from code42cli.options import sdk_options, OrderedGroup + +risk_tag_option = click.option( + "-t", + "--risk-tag", + multiple=True, + type=click.Choice(RiskTags()), + help="Risk tags associated with the employee.", +) + + +@click.group(cls=OrderedGroup) +@sdk_options +def high_risk_employee(state): + """For adding and removing employees from the high risk employee detection list.""" + pass + + +@high_risk_employee.command() +@cloud_alias_option +@notes_option +@risk_tag_option +@username_arg +@sdk_options +def add(state, username, cloud_alias, risk_tag, notes): + """Add a user to the high-risk-employee detection list.""" + _add_high_risk_employee(state.sdk, username, cloud_alias, risk_tag, notes) + + +@high_risk_employee.command() +@username_arg +@sdk_options +def remove(state, username): + """Remove a user from the high-risk-employee detection list.""" + _remove_high_risk_employee(state.sdk, username) + + +@high_risk_employee.command() +@username_arg +@risk_tag_option +@sdk_options +def add_risk_tags(state, username, risk_tag): + """Associates risk tags with a user.""" + _add_risk_tags(state.sdk, username, risk_tag) + + +@high_risk_employee.command() +@username_arg +@risk_tag_option +@sdk_options +def remove_risk_tags(state, username, risk_tag): + """Disassociates risk tags from a user.""" + _remove_risk_tags(state.sdk, username, risk_tag) + + +@high_risk_employee.group(cls=OrderedGroup) +@sdk_options +def bulk(state): + """Tools for executing bulk high risk employee actions.""" + pass + + +HIGH_RISK_EMPLOYEE_CSV_HEADERS = ["username", "cloud_alias", "risk_tag", "notes"] +RISK_TAG_CSV_HEADERS = ["username", "tag"] + +high_risk_employee_generate_template = generate_template_cmd_factory( + group_name="high_risk_employee", + commands_dict={ + "add": HIGH_RISK_EMPLOYEE_CSV_HEADERS, + "remove": "username", + "add-risk-tags": RISK_TAG_CSV_HEADERS, + "remove-risk-tags": RISK_TAG_CSV_HEADERS, + }, +) +bulk.add_command(high_risk_employee_generate_template) + + +@bulk.command( + help="Bulk add users to the high-risk-employee detection list using a csv file with " + "format: {}".format(",".join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)) +) +@read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) +@sdk_options +def add(state, csv_rows): + row_handler = lambda username, cloud_alias, risk_tag, notes: _add_high_risk_employee( + state.sdk, username, cloud_alias, risk_tag, notes + ) + run_bulk_process( + row_handler, csv_rows, progress_label="Adding users to high risk employee detection list:" + ) + + +@bulk.command( + help="Bulk remove users from the high-risk-employee detection list using a newline separated " + "file of usernames." +) +@read_flat_file_arg +@sdk_options +def remove(state, file_rows): + row_handler = lambda username: _remove_high_risk_employee(state.sdk, username) + run_bulk_process( + row_handler, + file_rows, + progress_label="Removing users from high risk employee detection list:", + ) + + +@bulk.command( + help="Adds risk tags to users in bulk using a csv file with format: {}".format( + ",".join(RISK_TAG_CSV_HEADERS) + ) +) +@read_csv_arg(headers=RISK_TAG_CSV_HEADERS) +@sdk_options +def add_risk_tags(state, csv_rows): + row_handler = lambda username, tag: _add_risk_tags(state.sdk, username, tag) + run_bulk_process( + row_handler, csv_rows, progress_label="Adding risk tags to users:", + ) + + +@bulk.command( + help="Removes risk tags from users in bulk using a csv file with format: {}".format( + ",".join(RISK_TAG_CSV_HEADERS) + ) +) +@read_csv_arg(headers=RISK_TAG_CSV_HEADERS) +@sdk_options +def remove_risk_tags(state, csv_rows): + row_handler = lambda username, tag: _remove_risk_tags(state.sdk, username, tag) + run_bulk_process( + row_handler, csv_rows, progress_label="Removing risk tags from users:", + ) + + +def _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes): + risk_tag = handle_list_args(risk_tag) + user_id = get_user_id(sdk, username) + + try: + sdk.detectionlists.high_risk_employee.add(user_id) + update_user(sdk, username, cloud_alias=cloud_alias, risk_tag=risk_tag, notes=notes) + except Py42BadRequestError as err: + try_handle_user_already_added_error(err, username, "high-risk-employee list") + raise + + +def _remove_high_risk_employee(sdk, username): + """Removes an employee from the high risk employee detection list. + + Args: + sdk (py42.sdk.SDKClient): py42. + profile (C42Profile): Your code42 profile. + username (str): The username of the employee to remove. + """ + user_id = get_user_id(sdk, username) + sdk.detectionlists.high_risk_employee.remove(user_id) diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py new file mode 100644 index 000000000..ce60e51c6 --- /dev/null +++ b/src/code42cli/cmds/legal_hold.py @@ -0,0 +1,230 @@ +from collections import OrderedDict +from functools import lru_cache +from pprint import pformat + +import click +from click import echo +from py42.exceptions import Py42ForbiddenError, Py42BadRequestError + +from code42cli.bulk import run_bulk_process, generate_template_cmd_factory +from code42cli.cmds.shared import get_user_id +from code42cli.errors import ( + UserAlreadyAddedError, + UserNotInLegalHoldError, + LegalHoldNotFoundOrPermissionDeniedError, +) +from code42cli.file_readers import read_csv_arg +from code42cli.options import sdk_options, OrderedGroup +from code42cli.util import ( + format_to_table, + find_format_width, + format_string_list_to_columns, +) + +_MATTER_KEYS_MAP = OrderedDict() +_MATTER_KEYS_MAP["legalHoldUid"] = "Matter ID" +_MATTER_KEYS_MAP["name"] = "Name" +_MATTER_KEYS_MAP["description"] = "Description" +_MATTER_KEYS_MAP["creator_username"] = "Creator" +_MATTER_KEYS_MAP["creationDate"] = "Creation Date" + + +@click.group(cls=OrderedGroup) +@sdk_options +def legal_hold(state): + """For adding and removing employees to legal hold matters.""" + pass + + +matter_id_option = click.option( + "-m", + "--matter-id", + required=True, + type=str, + help="ID of the legal hold matter user will be added to.", +) +user_id_option = click.option( + "-u", + "--username", + required=True, + type=str, + help="The username of the user to add to the matter.", +) + + +@legal_hold.command() +@matter_id_option +@user_id_option +@sdk_options +def add_user(state, matter_id, username): + """Add a user to a legal hold matter.""" + _add_user_to_legal_hold(state.sdk, matter_id, username) + + +@legal_hold.command() +@matter_id_option +@user_id_option +@sdk_options +def remove_user(state, matter_id, username): + """Remove a user from a legal hold matter.""" + _remove_user_from_legal_hold(state.sdk, matter_id, username) + + +@legal_hold.command("list") +@sdk_options +def _list(state): + """Fetch existing legal hold matters.""" + matters = _get_all_active_matters(state.sdk) + if matters: + rows, column_size = find_format_width(matters, _MATTER_KEYS_MAP) + format_to_table(rows, column_size) + + +@legal_hold.command() +@click.argument("matter-id") +@click.option("--include-inactive", is_flag=True) +@click.option("--include-policy", is_flag=True) +@sdk_options +def show(state, matter_id, include_inactive=False, include_policy=False): + """Display details of a given legal hold matter.""" + matter = _check_matter_is_accessible(state.sdk, matter_id) + matter["creator_username"] = matter["creator"]["username"] + + # if `active` is None then all matters (whether active or inactive) are returned. True returns + # only those that are active. + active = None if include_inactive else True + memberships = _get_legal_hold_memberships_for_matter(state.sdk, matter_id, active=active) + active_usernames = [member["user"]["username"] for member in memberships if member["active"]] + inactive_usernames = [ + member["user"]["username"] for member in memberships if not member["active"] + ] + + rows, column_size = find_format_width([matter], _MATTER_KEYS_MAP) + + echo("") + format_to_table(rows, column_size) + _print_matter_members(active_usernames, member_type="active") + + if include_inactive: + _print_matter_members(inactive_usernames, member_type="inactive") + + if include_policy: + _get_and_print_preservation_policy(state.sdk, matter["holdPolicyUid"]) + echo("") + + +@legal_hold.group(cls=OrderedGroup) +@sdk_options +def bulk(state): + """Tools for executing bulk legal hold actions.""" + pass + + +LEGAL_HOLD_CSV_HEADERS = ["matter_id", "username"] + + +legal_hold_generate_template = generate_template_cmd_factory( + group_name="legal_hold", + commands_dict={"add": LEGAL_HOLD_CSV_HEADERS, "remove": LEGAL_HOLD_CSV_HEADERS}, +) +bulk.add_command(legal_hold_generate_template) + + +@bulk.command( + help="Bulk add users to legal hold matters from a csv file. CSV file format: {}".format( + ",".join(LEGAL_HOLD_CSV_HEADERS) + ) +) +@read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) +@sdk_options +def add(state, csv_rows): + row_handler = lambda matter_id, username: _add_user_to_legal_hold( + state.sdk, matter_id, username + ) + run_bulk_process(row_handler, csv_rows, progress_label="Adding users to legal hold:") + + +@bulk.command( + help="Bulk remove users from legal hold matters from a csv file. CSV file format: {}".format( + ",".join(LEGAL_HOLD_CSV_HEADERS) + ) +) +@read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) +@sdk_options +def remove(state, csv_rows): + row_handler = lambda matter_id, username: _remove_user_from_legal_hold( + state.sdk, matter_id, username + ) + run_bulk_process(row_handler, csv_rows, progress_label="Removing users from legal hold:") + + +def _add_user_to_legal_hold(sdk, matter_id, username): + user_id = get_user_id(sdk, username) + matter = _check_matter_is_accessible(sdk, matter_id) + try: + sdk.legalhold.add_to_matter(user_id, matter_id) + except Py42BadRequestError as e: + if "USER_ALREADY_IN_HOLD" in e.response.text: + matter_id_and_name_text = "legal hold matter id={}, name={}".format( + matter_id, matter["name"] + ) + raise UserAlreadyAddedError(username, matter_id_and_name_text) + raise + + +def _remove_user_from_legal_hold(sdk, matter_id, username): + _check_matter_is_accessible(sdk, matter_id) + membership_id = _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id) + sdk.legalhold.remove_from_matter(membership_id) + + +def _get_and_print_preservation_policy(sdk, policy_uid): + preservation_policy = sdk.legalhold.get_policy_by_uid(policy_uid) + echo("\nPreservation Policy:\n") + echo(pformat(preservation_policy._data_root)) + + +def _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id): + user_id = get_user_id(sdk, username) + memberships = _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True) + for member in memberships: + if member["user"]["userUid"] == user_id: + return member["legalHoldMembershipUid"] + raise UserNotInLegalHoldError(username, matter_id) + + +def _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True): + memberships_generator = sdk.legalhold.get_all_matter_custodians( + legal_hold_uid=matter_id, active=active + ) + memberships = [ + member for page in memberships_generator for member in page["legalHoldMemberships"] + ] + return memberships + + +def _get_all_active_matters(sdk): + matters_generator = sdk.legalhold.get_all_matters() + matters = [ + matter for page in matters_generator for matter in page["legalHolds"] if matter["active"] + ] + for matter in matters: + matter["creator_username"] = matter["creator"]["username"] + return matters + + +def _print_matter_members(username_list, member_type="active"): + if username_list: + echo("\n{} matter members:\n".format(member_type.capitalize())) + format_string_list_to_columns(username_list) + else: + echo("No {} matter members.\n".format(member_type)) + + +@lru_cache(maxsize=None) +def _check_matter_is_accessible(sdk, matter_id): + try: + matter = sdk.legalhold.get_matter_by_uid(matter_id) + return matter + except (Py42BadRequestError, Py42ForbiddenError): + raise LegalHoldNotFoundOrPermissionDeniedError(matter_id) diff --git a/src/code42cli/cmds/legal_hold/__init__.py b/src/code42cli/cmds/legal_hold/__init__.py deleted file mode 100644 index 81b806e41..000000000 --- a/src/code42cli/cmds/legal_hold/__init__.py +++ /dev/null @@ -1,146 +0,0 @@ -from collections import OrderedDict -from functools import lru_cache -from pprint import pprint - -from py42.exceptions import Py42ForbiddenError, Py42BadRequestError - - -from code42cli.errors import ( - UserAlreadyAddedError, - UserNotInLegalHoldError, - LegalHoldNotFoundOrPermissionDeniedError, -) -from code42cli.util import ( - format_to_table, - find_format_width, - format_string_list_to_columns, - get_user_id, -) -from code42cli.bulk import run_bulk_process -from code42cli.file_readers import create_csv_reader -from code42cli.logger import get_main_cli_logger - -_MATTER_KEYS_MAP = OrderedDict() -_MATTER_KEYS_MAP[u"legalHoldUid"] = u"Matter ID" -_MATTER_KEYS_MAP[u"name"] = u"Name" -_MATTER_KEYS_MAP[u"description"] = u"Description" -_MATTER_KEYS_MAP[u"creator_username"] = u"Creator" -_MATTER_KEYS_MAP[u"creationDate"] = u"Creation Date" - -logger = get_main_cli_logger() - - -def add_user(sdk, matter_id, username): - user_id = get_user_id(sdk, username) - matter = _check_matter_is_accessible(sdk, matter_id) - try: - sdk.legalhold.add_to_matter(user_id, matter_id) - except Py42BadRequestError as e: - if u"USER_ALREADY_IN_HOLD" in e.response.text: - matter_id_and_name_text = u"legal hold matter id={}, name={}".format( - matter_id, matter[u"name"] - ) - raise UserAlreadyAddedError(username, matter_id_and_name_text) - raise - - -def remove_user(sdk, matter_id, username): - _check_matter_is_accessible(sdk, matter_id) - membership_id = _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id) - sdk.legalhold.remove_from_matter(membership_id) - - -def get_matters(sdk): - matters = _get_all_active_matters(sdk) - if matters: - rows, column_size = find_format_width(matters, _MATTER_KEYS_MAP) - format_to_table(rows, column_size) - - -def add_bulk_users(sdk, file_name): - reader = create_csv_reader(file_name) - run_bulk_process(lambda matter_id, username: add_user(sdk, matter_id, username), reader) - - -def remove_bulk_users(sdk, file_name): - reader = create_csv_reader(file_name) - run_bulk_process(lambda matter_id, username: remove_user(sdk, matter_id, username), reader) - - -def show_matter(sdk, matter_id, include_inactive=False, include_policy=False): - matter = _check_matter_is_accessible(sdk, matter_id) - matter[u"creator_username"] = matter[u"creator"][u"username"] - - # if `active` is None then all matters (whether active or inactive) are returned. True returns - # only those that are active. - active = None if include_inactive else True - memberships = _get_legal_hold_memberships_for_matter(sdk, matter_id, active=active) - active_usernames = [member[u"user"][u"username"] for member in memberships if member[u"active"]] - inactive_usernames = [ - member[u"user"][u"username"] for member in memberships if not member[u"active"] - ] - - rows, column_size = find_format_width([matter], _MATTER_KEYS_MAP) - - print(u"") - format_to_table(rows, column_size) - if active_usernames: - print(u"\nActive matter members:\n") - format_string_list_to_columns(active_usernames) - else: - print("\nNo active matter members.\n") - - if include_inactive: - if inactive_usernames: - print(u"\nInactive matter members:\n") - format_string_list_to_columns(inactive_usernames) - else: - print("No inactive matter members.\n") - - if include_policy: - _get_and_print_preservation_policy(sdk, matter[u"holdPolicyUid"]) - print(u"") - - -def _get_and_print_preservation_policy(sdk, policy_uid): - preservation_policy = sdk.legalhold.get_policy_by_uid(policy_uid) - print(u"\nPreservation Policy:\n") - pprint(preservation_policy._data_root) - - -def _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id): - user_id = get_user_id(sdk, username) - memberships = _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True) - for member in memberships: - if member[u"user"][u"userUid"] == user_id: - return member[u"legalHoldMembershipUid"] - raise UserNotInLegalHoldError(username, matter_id) - - -def _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True): - memberships_generator = sdk.legalhold.get_all_matter_custodians( - legal_hold_uid=matter_id, active=active - ) - memberships = [ - member for page in memberships_generator for member in page[u"legalHoldMemberships"] - ] - return memberships - - -def _get_all_active_matters(sdk): - matters_generator = sdk.legalhold.get_all_matters() - matters = [ - matter for page in matters_generator for matter in page[u"legalHolds"] if matter[u"active"] - ] - for matter in matters: - matter[u"creator_username"] = matter[u"creator"][u"username"] - return matters - - -@lru_cache(maxsize=None) -def _check_matter_is_accessible(sdk, matter_id): - try: - matter = sdk.legalhold.get_matter_by_uid(matter_id) - return matter - except (Py42BadRequestError, Py42ForbiddenError): - raise LegalHoldNotFoundOrPermissionDeniedError(matter_id) diff --git a/src/code42cli/cmds/legal_hold/commands.py b/src/code42cli/cmds/legal_hold/commands.py deleted file mode 100644 index a98363213..000000000 --- a/src/code42cli/cmds/legal_hold/commands.py +++ /dev/null @@ -1,168 +0,0 @@ -import os - -from code42cli.args import ArgConfig -from code42cli.commands import Command, SubcommandLoader -from code42cli.bulk import generate_template, BulkCommandType -from code42cli.cmds.legal_hold import ( - add_user, - remove_user, - get_matters, - add_bulk_users, - remove_bulk_users, - show_matter, -) - - -class LegalHoldSubcommandLoader(SubcommandLoader): - ADD_USER = "add-user" - REMOVE_USER = "remove-user" - LIST = "list" - SHOW = "show" - BULK = "bulk" - - def __init__(self, root_command_name): - super(LegalHoldSubcommandLoader, self).__init__(root_command_name) - self._bulk_subcommand_loader = LegalHoldBulkSubcommandLoader(self.BULK) - - def load_commands(self): - """Sets up the `legal-hold` subcommand with all of its subcommands.""" - usage_prefix = u"code42 legal-hold" - - add = Command( - self.ADD_USER, - u"Add a user to a legal hold matter.", - u"{} add-user --matter-id --username ".format(usage_prefix), - handler=add_user, - arg_customizer=_customize_add_arguments, - ) - - remove = Command( - self.REMOVE_USER, - u"Remove a user from a legal hold matter.", - u"{} remove-user --matter-id --username ".format(usage_prefix), - handler=remove_user, - arg_customizer=_customize_remove_arguments, - ) - - list_matters = Command( - self.LIST, - u"Fetch existing legal hold matters.", - u"{} list".format(usage_prefix), - handler=get_matters, - ) - - show = Command( - self.SHOW, - u"Fetch all legal hold custodians for a given matter.", - u"{} show ".format(usage_prefix), - handler=show_matter, - arg_customizer=_customize_show_arguments, - ) - - bulk = Command( - self.BULK, - u"Tools for executing bulk commands.", - subcommand_loader=self._bulk_subcommand_loader, - ) - - return [add, remove, list_matters, show, bulk] - - -class LegalHoldBulkSubcommandLoader(SubcommandLoader): - GENERATE_TEMPLATE = u"generate-template" - ADD = u"add" - REMOVE = u"remove" - - def load_commands(self): - """Sets up the `legal-hold bulk` subcommands.""" - usage_prefix = u"code42 legal-hold bulk" - - bulk_add = Command( - u"add-user", - u"Bulk add users to legal hold matters from a csv file. CSV file format: matter_id,username", - u"{} add-user ".format(usage_prefix), - handler=add_bulk_users, - arg_customizer=_customize_bulk_arguments, - ) - - bulk_remove = Command( - u"remove-user", - u"Bulk remove users from legal hold matters from a csv file. CSV file format: matter_id,username", - u"{} remove-user ".format(usage_prefix), - handler=remove_bulk_users, - arg_customizer=_customize_bulk_arguments, - ) - - generate_template_cmd = Command( - u"generate-template", - u"Generate the necessary csv template needed for bulk adding users.", - u"{} generate-template ".format(usage_prefix), - handler=_generate_template_file, - arg_customizer=_load_bulk_generate_template_description, - ) - - return [bulk_add, bulk_remove, generate_template_cmd] - - -def _customize_add_arguments(argument_collection): - matter = argument_collection.arg_configs["matter_id"] - matter.add_short_option_name("-m") - matter.set_help("ID of the legal hold matter user will be added to. Required.") - username = argument_collection.arg_configs["username"] - username.add_short_option_name("-u") - username.set_help("The username of the user to add to the matter. Required.") - - -def _customize_remove_arguments(argument_collection): - matter = argument_collection.arg_configs["matter_id"] - matter.add_short_option_name("-m") - matter.set_help("ID of the legal hold matter user will be removed from. Required.") - username = argument_collection.arg_configs["username"] - username.add_short_option_name("-u") - username.set_help("The username of the user to remove from the matter. Required.") - - -def _customize_show_arguments(argument_collection): - matter_id = argument_collection.arg_configs[u"matter_id"] - matter_id.set_help(u"ID of the legal hold matter.") - args = { - u"include_inactive": ArgConfig( - u"--include-inactive", - action=u"store_true", - help=u"Include list of users who are no longer actively on this matter.", - ), - u"include_policy": ArgConfig( - u"--include-policy", - action=u"store_true", - help=u"Include the preservation policy (in json format) for this matter.", - ), - } - argument_collection.extend(args) - - -def _customize_bulk_arguments(argument_collection): - file_name = argument_collection.arg_configs[u"file_name"] - file_name.set_help( - u"The path to the csv file with columns 'matter_id,username' " - u"for bulk adding users to legal hold." - ) - - -def _generate_template_file(cmd, path=None): - handler = None - filename = u"legal_hold.csv" - if cmd == BulkCommandType.ADD: - handler = add_user - filename = u"add_users_to_{}".format(filename) - elif cmd == BulkCommandType.REMOVE: - handler = remove_user - filename = u"remove_users_from_{}".format(filename) - if not path: - path = os.path.join(os.getcwd(), filename) - generate_template(handler, path) - - -def _load_bulk_generate_template_description(argument_collection): - cmd_type = argument_collection.arg_configs[u"cmd"] - cmd_type.set_help(u"The type of command the template will be used for.") - cmd_type.set_choices(BulkCommandType()) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 193449823..5ade7ed26 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -1,218 +1,149 @@ from getpass import getpass -from code42cli import MAIN_COMMAND +import click +from click import echo, secho + import code42cli.profile as cliprofile -from code42cli.compat import str -from code42cli.profile import print_and_log_no_existing_profile -from code42cli.args import PROFILE_HELP -from code42cli.commands import Command, SubcommandLoader +from code42cli.errors import Code42CLIError +from code42cli.profile import CREATE_PROFILE_HELP from code42cli.sdk_client import validate_connection from code42cli.util import does_user_agree -from code42cli.logger import get_main_cli_logger - - -class ProfileSubcommandLoader(SubcommandLoader): - SHOW = u"show" - LIST = u"list" - USE = u"use" - RESET_PW = u"reset-pw" - CREATE = u"create" - UPDATE = u"update" - DELETE = u"delete" - DELETE_ALL = u"delete-all" - - def load_commands(self): - """Sets up the `profile` subcommand with all of its subcommands.""" - usage_prefix = u"{} profile".format(MAIN_COMMAND) - - show = Command( - self.SHOW, - u"Print the details of a profile.", - u"{} {}".format(usage_prefix, u"show "), - handler=show_profile, - arg_customizer=_load_optional_profile_description, - ) - - list_all = Command( - self.LIST, - u"Show all existing stored profiles.", - u"{} {}".format(usage_prefix, u"list"), - handler=list_profiles, - ) - - use = Command( - self.USE, - u"Set a profile as the default.", - u"{} {}".format(usage_prefix, u"use "), - handler=use_profile, - ) - - reset_pw = Command( - self.RESET_PW, - u"Change the stored password for a profile.", - u"{} {}".format(usage_prefix, u"reset-pw "), - handler=prompt_for_password_reset, - arg_customizer=_load_optional_profile_description, - ) - - create = Command( - self.CREATE, - u"Create profile settings. The first profile created will be the default.", - u"{} {}".format( - usage_prefix, - u"create --name --server --username ", - ), - handler=create_profile, - arg_customizer=_load_profile_create_descriptions, - ) - - update = Command( - self.UPDATE, - u"Update an existing profile.", - u"{} {}".format(usage_prefix, u"update "), - handler=update_profile, - arg_customizer=_load_profile_update_descriptions, - ) - - delete = Command( - self.DELETE, - u"Deletes a profile and its stored password (if any).", - u"{} {}".format(usage_prefix, u"delete "), - handler=delete_profile, - ) - - delete_all = Command( - self.DELETE_ALL, - u"Deletes all profiles and saved passwords (if any).", - u"{} {}".format(usage_prefix, u"delete-all"), - handler=delete_all_profiles, - ) - - return [show, list_all, use, reset_pw, create, update, delete, delete_all] - - -def show_profile(name=None): - """Prints the given profile to stdout.""" - c42profile = cliprofile.get_profile(name) - logger = get_main_cli_logger() - logger.print_info(u"\n{0}:".format(c42profile.name)) - logger.print_info(u"\t* username = {}".format(c42profile.username)) - logger.print_info(u"\t* authority url = {}".format(c42profile.authority_url)) - logger.print_info(u"\t* ignore-ssl-errors = {}".format(c42profile.ignore_ssl_errors)) - if cliprofile.get_stored_password(c42profile.name) is not None: - logger.print_info(u"\t* A password is set.") - logger.print_info(u"") -def create_profile(name, server, username, disable_ssl_errors=False): +@click.group() +def profile(): + """For managing Code42 settings.""" + pass + + +profile_name_arg = click.argument("profile_name", required=False) +name_option = click.option( + "-n", + "--name", + required=True, + type=str, + help="The name of the Code42 CLI profile to use when executing this command.", +) +server_option = click.option( + "-s", "--server", required=True, type=str, help="The url and port of the Code42 server." +) +username_option = click.option( + "-u", "--username", required=True, type=str, help="The username of the Code42 API user." +) +disable_ssl_option = click.option( + "--disable-ssl-errors", + is_flag=True, + help="For development purposes, do not validate the SSL certificates of Code42 servers. " + "This is not recommended unless it is required.", +) + + +@profile.command() +@profile_name_arg +def show(profile_name): + """Print the details of a profile.""" + c42profile = cliprofile.get_profile(profile_name) + echo("\n{0}:".format(c42profile.name)) + echo("\t* username = {}".format(c42profile.username)) + echo("\t* authority url = {}".format(c42profile.authority_url)) + echo("\t* ignore-ssl-errors = {}".format(c42profile.ignore_ssl_errors)) + if cliprofile.get_stored_password(c42profile.name) is not None: + echo("\t* A password is set.") + echo("") + echo("") + + +@profile.command() +@name_option +@server_option +@username_option +@disable_ssl_option +def create(name, server, username, disable_ssl_errors=False): + """Create profile settings. The first profile created will be the default.""" cliprofile.create_profile(name, server, username, disable_ssl_errors) _prompt_for_allow_password_set(name) - get_main_cli_logger().print_info(u"Successfully created profile '{}'.".format(name)) + echo("Successfully created profile '{}'.".format(name)) -def update_profile(name=None, server=None, username=None, disable_ssl_errors=None): +@profile.command() +@name_option +@server_option +@username_option +@disable_ssl_option +def update(name=None, server=None, username=None, disable_ssl_errors=None): + """Update an existing profile.""" profile = cliprofile.get_profile(name) cliprofile.update_profile(profile.name, server, username, disable_ssl_errors) _prompt_for_allow_password_set(profile.name) - get_main_cli_logger().print_info(u"Profile '{}' has been updated.".format(profile.name)) + echo("Profile '{}' has been updated.".format(profile.name)) -def prompt_for_password_reset(name=None): - """Securely prompts for your password and then stores it using keyring.""" - c42profile = cliprofile.get_profile(name) - new_password = getpass() - _validate_connection(c42profile.authority_url, c42profile.username, new_password) - cliprofile.set_password(new_password, c42profile.name) +@profile.command() +@profile_name_arg +def reset_pw(profile_name=None): + """Change the stored password for a profile.""" + _reset_pw(profile_name) -def _validate_connection(authority, username, password): - if not validate_connection(authority, username, password): - logger = get_main_cli_logger() - logger.print_and_log_error( - u"Your credentials failed to validate, so your password was not stored." - u"Check your network connection and the spelling of your username and server URL." - ) - exit(1) - - -def list_profiles(*args): - """Lists all profiles that exist for this OS user.""" +@profile.command("list") +def _list(): + """Show all existing stored profiles.""" profiles = cliprofile.get_all_profiles() - logger = get_main_cli_logger() if not profiles: - print_and_log_no_existing_profile() - return + raise Code42CLIError("No existing profile.", help=CREATE_PROFILE_HELP) for profile in profiles: - logger.print_info(str(profile)) + echo(str(profile)) -def use_profile(name): - """Changes the default profile to the given one.""" - cliprofile.switch_default_profile(name) +@profile.command() +@profile_name_arg +def use(profile_name): + """Set a profile as the default.""" + cliprofile.switch_default_profile(profile_name) -def delete_profile(name): - logger = get_main_cli_logger() - if cliprofile.is_default_profile(name): - logger.print_info(u"\n{} is currently the default profile!".format(name)) +@profile.command() +@profile_name_arg +def delete(profile_name): + """Deletes a profile and its stored password (if any).""" + if cliprofile.is_default_profile(profile_name): + echo("\n{} is currently the default profile!".format(profile_name)) if not does_user_agree( - u"\nDeleting this profile will also delete any stored passwords and checkpoints. " - u"Are you sure? (y/n): " + "\nDeleting this profile will also delete any stored passwords and checkpoints. " + "Are you sure? (y/n): " ): return - cliprofile.delete_profile(name) + cliprofile.delete_profile(profile_name) + echo("Profile '{}' has been deleted.".format(profile_name)) -def delete_all_profiles(): +@profile.command() +def delete_all(): + """Deletes all profiles and saved passwords (if any).""" existing_profiles = cliprofile.get_all_profiles() - logger = get_main_cli_logger() if existing_profiles: - logger.print_info(u"\nAre you sure you want to delete the following profiles?") + echo("\nAre you sure you want to delete the following profiles?") for profile in existing_profiles: - logger.print_info(u"\t{}".format(profile.name)) - if does_user_agree( - u"\nThis will also delete any stored passwords and checkpoints. (y/n): " - ): + echo("\t{}".format(profile.name)) + if does_user_agree("\nThis will also delete any stored passwords and checkpoints. (y/n): "): for profile in existing_profiles: cliprofile.delete_profile(profile.name) + echo("Profile '{}' has been deleted.".format(profile.name)) else: - logger.print_info(u"\nNo profiles exist. Nothing to delete.") - + echo("\nNo profiles exist. Nothing to delete.") -def _load_optional_profile_description(argument_collection): - profile = argument_collection.arg_configs[u"name"] - profile.add_short_option_name(u"-n") - profile.set_help(PROFILE_HELP) - -def _load_profile_create_descriptions(argument_collection): - profile = argument_collection.arg_configs[u"name"] - profile.set_help(PROFILE_HELP) - profile.add_short_option_name(u"-n") - argument_collection.arg_configs[u"server"].add_short_option_name(u"-s") - argument_collection.arg_configs[u"username"].add_short_option_name(u"-u") - _load_profile_settings_descriptions(argument_collection) - - -def _load_profile_update_descriptions(argument_collection): - _load_optional_profile_description(argument_collection) - _load_profile_settings_descriptions(argument_collection) - argument_collection.arg_configs[u"server"].add_short_option_name(u"-s") - argument_collection.arg_configs[u"username"].add_short_option_name(u"-u") - - -def _load_profile_settings_descriptions(argument_collection): - server = argument_collection.arg_configs[u"server"] - username = argument_collection.arg_configs[u"username"] - disable_ssl_errors = argument_collection.arg_configs[u"disable_ssl_errors"] - server.set_help(u"The url and port of the Code42 server.") - username.set_help(u"The username of the Code42 API user.") - disable_ssl_errors.set_help( - u"For development purposes, do not validate the SSL certificates of Code42 servers. " - u"This is not recommended unless it is required." - ) +def _prompt_for_allow_password_set(profile_name): + if does_user_agree("Would you like to set a password? (y/n): "): + _reset_pw(profile_name) -def _prompt_for_allow_password_set(profile_name): - if does_user_agree(u"Would you like to set a password? (y/n): "): - prompt_for_password_reset(profile_name) +def _reset_pw(profile_name): + c42profile = cliprofile.get_profile(profile_name) + new_password = getpass() + try: + validate_connection(c42profile.authority_url, c42profile.username, new_password) + except Exception: + secho("Password not stored!", bold=True) + raise + cliprofile.set_password(new_password, c42profile.name) diff --git a/src/code42cli/cmds/alerts/__init__.py b/src/code42cli/cmds/search/__init__.py similarity index 100% rename from src/code42cli/cmds/alerts/__init__.py rename to src/code42cli/cmds/search/__init__.py diff --git a/src/code42cli/cmds/search_shared/cursor_store.py b/src/code42cli/cmds/search/cursor_store.py similarity index 98% rename from src/code42cli/cmds/search_shared/cursor_store.py rename to src/code42cli/cmds/search/cursor_store.py index 33295d888..7a15a32d3 100644 --- a/src/code42cli/cmds/search_shared/cursor_store.py +++ b/src/code42cli/cmds/search/cursor_store.py @@ -1,5 +1,3 @@ -from __future__ import with_statement - import os from os import path diff --git a/src/code42cli/cmds/search/enums.py b/src/code42cli/cmds/search/enums.py new file mode 100644 index 000000000..490b0dfed --- /dev/null +++ b/src/code42cli/cmds/search/enums.py @@ -0,0 +1,95 @@ +IS_CHECKPOINT_KEY = "use_checkpoint" + + +class OutputFormat(object): + CEF = "CEF" + JSON = "JSON" + RAW = "RAW-JSON" + + def __iter__(self): + return iter([self.CEF, self.JSON, self.RAW]) + + +class AlertOutputFormat(object): + JSON = "JSON" + RAW = "RAW-JSON" + + def __iter__(self): + return iter([self.JSON, self.RAW]) + + +class AlertSeverity(object): + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [self.HIGH, self.MEDIUM, self.LOW] + + +class AlertState(object): + OPEN = "OPEN" + DISMISSED = "RESOLVED" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [self.OPEN, self.DISMISSED] + + +class ExposureType(object): + SHARED_VIA_LINK = "SharedViaLink" + SHARED_TO_DOMAIN = "SharedToDomain" + APPLICATION_READ = "ApplicationRead" + CLOUD_STORAGE = "CloudStorage" + REMOVABLE_MEDIA = "RemovableMedia" + IS_PUBLIC = "IsPublic" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [ + self.SHARED_VIA_LINK, + self.SHARED_TO_DOMAIN, + self.APPLICATION_READ, + self.CLOUD_STORAGE, + self.REMOVABLE_MEDIA, + self.IS_PUBLIC, + ] + + +class RuleType(object): + ENDPOINT_EXFILTRATION = "FedEndpointExfiltration" + CLOUD_SHARE_PERMISSIONS = "FedCloudSharePermissions" + FILE_TYPE_MISMATCH = "FedFileTypeMismatch" + + def __iter__(self): + return iter(self._as_list()) + + def __len__(self): + return len(self._as_list()) + + def _as_list(self): + return [self.ENDPOINT_EXFILTRATION, self.CLOUD_SHARE_PERMISSIONS, self.FILE_TYPE_MISMATCH] + + +class ServerProtocol(object): + TCP = "TCP" + UDP = "UDP" + + def __iter__(self): + return iter([self.TCP, self.UDP]) diff --git a/src/code42cli/cmds/search_shared/extraction.py b/src/code42cli/cmds/search/extraction.py similarity index 51% rename from src/code42cli/cmds/search_shared/extraction.py rename to src/code42cli/cmds/search/extraction.py index be2e60e70..88230bc3a 100644 --- a/src/code42cli/cmds/search_shared/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -1,36 +1,31 @@ import json from c42eventextractor import ExtractionHandlers +from click import secho from py42.sdk.queries.query_filter import QueryFilterTimestampField import code42cli.errors as errors -from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp, verify_timestamp_order +from code42cli.date_helper import verify_timestamp_order from code42cli.logger import get_main_cli_logger -from code42cli.cmds.alerts.util import get_alert_details from code42cli.util import warn_interrupt logger = get_main_cli_logger() +_ALERT_DETAIL_BATCH_SIZE = 100 -def begin_date_is_required(args, cursor_store): - if not args.use_checkpoint: - return True - is_required = cursor_store and cursor_store.get(args.use_checkpoint) is None - # Ignore begin date when in use-checkpoint mode, it is not required, and it was passed an argument. - if not is_required and args.begin: - logger.print_and_log_info( - u"Ignoring --begin value as --use-checkpoint was passed and cursor checkpoint exists.\n" - ) - args.begin = None - return is_required - - -def verify_begin_date_requirements(args, cursor_store): - if begin_date_is_required(args, cursor_store) and not args.begin: - logger.print_and_log_error(u"'begin date' is required.\n") - logger.print_bold(u"Try using '-b' or '--begin'. Use `-h` for more info.\n") - exit(1) +def _get_alert_details(sdk, alert_summary_list): + alert_ids = [alert["id"] for alert in alert_summary_list] + batches = [ + alert_ids[i : i + _ALERT_DETAIL_BATCH_SIZE] + for i in range(0, len(alert_ids), _ALERT_DETAIL_BATCH_SIZE) + ] + results = [] + for batch in batches: + r = sdk.alerts.get_details(batch) + results.extend(r["alerts"]) + results = sorted(results, key=lambda x: x["createdAt"], reverse=True) + return results def create_handlers(sdk, extractor_class, output_logger, cursor_store, checkpoint_name): @@ -40,11 +35,12 @@ def create_handlers(sdk, extractor_class, output_logger, cursor_store, checkpoin def handle_error(exception): errors.ERRORED = True - if hasattr(exception, u"response") and hasattr(exception.response, u"text"): - message = u"{0}: {1}".format(exception, exception.response.text) + if hasattr(exception, "response") and hasattr(exception.response, "text"): + message = "{0}: {1}".format(exception, exception.response.text) else: message = exception - logger.print_and_log_error(message) + logger.log_error(message) + secho(str(message), err=True, fg="red") handlers.handle_error = handle_error @@ -53,14 +49,14 @@ def handle_error(exception): handlers.get_cursor_position = lambda: cursor_store.get(checkpoint_name) @warn_interrupt( - warning=u"Attempting to cancel cleanly to keep checkpoint data accurate. One moment..." + warning="Attempting to cancel cleanly to keep checkpoint data accurate. One moment..." ) def handle_response(response): response_dict = json.loads(response.text) events = response_dict.get(extractor._key) - if extractor._key == u"alerts": + if extractor._key == "alerts": try: - events = get_alert_details(sdk, events) + events = _get_alert_details(sdk, events) except Exception as ex: handlers.handle_error(ex) handlers.TOTAL_EVENTS += len(events) @@ -83,18 +79,14 @@ def create_time_range_filter(filter_cls, begin_date=None, end_date=None): end_date: The end date for the range. """ if not issubclass(filter_cls, QueryFilterTimestampField): - raise Exception(u"filter_cls must be a subclass of QueryFilterTimestampField") + raise Exception("filter_cls must be a subclass of QueryFilterTimestampField") if begin_date and end_date: - min_timestamp = parse_min_timestamp(begin_date) - max_timestamp = parse_max_timestamp(end_date) - verify_timestamp_order(min_timestamp, max_timestamp) - return filter_cls.in_range(min_timestamp, max_timestamp) + verify_timestamp_order(begin_date, end_date) + return filter_cls.in_range(begin_date, end_date) elif begin_date and not end_date: - min_timestamp = parse_min_timestamp(begin_date) - return filter_cls.on_or_after(min_timestamp) + return filter_cls.on_or_after(begin_date) elif end_date and not begin_date: - max_timestamp = parse_max_timestamp(end_date) - return filter_cls.on_or_before(max_timestamp) + return filter_cls.on_or_before(end_date) diff --git a/src/code42cli/cmds/search_shared/logger_factory.py b/src/code42cli/cmds/search/logger_factory.py similarity index 89% rename from src/code42cli/cmds/search_shared/logger_factory.py rename to src/code42cli/cmds/search/logger_factory.py index f553c5f78..afc8a2b9e 100644 --- a/src/code42cli/cmds/search_shared/logger_factory.py +++ b/src/code42cli/cmds/search/logger_factory.py @@ -7,15 +7,15 @@ ) from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper -from code42cli.cmds.search_shared.enums import OutputFormat -from code42cli.util import get_url_parts +from code42cli.cmds.search.enums import OutputFormat +from code42cli.errors import Code42CLIError from code42cli.logger import ( logger_has_handlers, logger_deps_lock, add_handler_to_logger, - get_main_cli_logger, get_logger_for_stdout as get_stdout_logger, ) +from code42cli.util import get_url_parts def get_logger_for_stdout(output_format): @@ -34,7 +34,7 @@ def get_logger_for_file(filename, output_format): filename: The name of the file to write logs to. output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. """ - logger = logging.getLogger(u"code42_file_{0}".format(output_format.lower())) + logger = logging.getLogger("code42_file_{0}".format(output_format.lower())) if logger_has_handlers(logger): return logger @@ -66,9 +66,7 @@ def get_logger_for_server(hostname, protocol, output_format): url_parts[0], port=port, protocol=protocol ).handler except: - logger = get_main_cli_logger() - logger.print_and_log_error(u"Unable to connect to {0}.".format(hostname)) - exit(1) + raise Code42CLIError("Unable to connect to {0}.".format(hostname)) return _init_logger(logger, handler, output_format) return logger diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py new file mode 100644 index 000000000..d63257fe5 --- /dev/null +++ b/src/code42cli/cmds/search/options.py @@ -0,0 +1,173 @@ +import json +from datetime import datetime, timezone + +import click + +from code42cli.cmds.search.enums import ServerProtocol +from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp +from code42cli.logger import get_main_cli_logger +from code42cli.options import incompatible_with + +logger = get_main_cli_logger() + + +def is_in_filter(filter_cls): + def callback(ctx, param, arg): + if arg: + ctx.obj.search_filters.append(filter_cls.is_in(arg)) + return arg + + return callback + + +def not_in_filter(filter_cls): + def callback(ctx, param, arg): + if arg: + ctx.obj.search_filters.append(filter_cls.not_in(arg)) + return arg + + return callback + + +def exists_filter(filter_cls): + def callback(ctx, param, arg): + if not arg: + ctx.obj.search_filters.append(filter_cls.exists()) + return arg + + return callback + + +def contains_filter(filter_cls): + def callback(ctx, param, arg): + if arg: + for item in arg: + ctx.obj.search_filters.append(filter_cls.contains(item)) + return arg + + return callback + + +def not_contains_filter(filter_cls): + def callback(ctx, param, arg): + if arg: + for item in arg: + ctx.obj.search_filters.append(filter_cls.not_contains(item)) + return arg + + return callback + + +def validate_advanced_query_is_json(ctx, param, arg): + if arg is None: + return + try: + json.loads(arg) + return arg + except json.JSONDecodeError: + raise click.ClickException("Failed to parse advanced query, must be a valid json string.") + + +AdvancedQueryAndSavedSearchIncompatible = incompatible_with(["advanced_query", "saved_search"]) + + +class BeginOption(AdvancedQueryAndSavedSearchIncompatible): + """click.Option subclass that enforces correct --begin option usage.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + # if ctx.obj is None it means we're in autocomplete mode and don't want to validate + if ctx.obj is not None and "saved_search" not in opts and "advanced_query" not in opts: + profile = opts.get("profile") or ctx.obj.profile.name + cursor = ctx.obj.cursor_getter(profile) + checkpoint_arg_present = "use_checkpoint" in opts + checkpoint_value = ( + cursor.get(opts.get("use_checkpoint", "")) if checkpoint_arg_present else None + ) + begin_present = "begin" in opts + if checkpoint_arg_present and checkpoint_value is not None and begin_present: + opts.pop("begin") + checkpoint_value_str = datetime.fromtimestamp( + checkpoint_value, timezone.utc + ).isoformat() + click.echo( + "Ignoring --begin value as --use-checkpoint was passed and checkpoint of {} exists.\n".format( + checkpoint_value_str + ), + err=True, + ) + if checkpoint_arg_present and checkpoint_value is None and not begin_present: + raise click.UsageError( + message="--begin date is required for --use-checkpoint when no checkpoint " + "exists yet.", + ) + if not checkpoint_arg_present and not begin_present: + raise click.UsageError(message="--begin date is required.") + return super().handle_parse_result(ctx, opts, args) + + +def create_search_options(search_term): + begin_option = click.option( + "-b", + "--begin", + callback=lambda ctx, param, arg: parse_min_timestamp(arg), + cls=BeginOption, + help="The beginning of the date range in which to look for {}, can be a date/time in " + "yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' " + "portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') " + "or a short value representing days (30d), hours (24h) or minutes (15m) from current " + "time.".format(search_term), + ) + end_option = click.option( + "-e", + "--end", + callback=lambda ctx, param, arg: parse_max_timestamp(arg), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="The end of the date range in which to look for {}, argument format options are " + "the same as --begin.".format(search_term), + ) + advanced_query_option = click.option( + "--advanced-query", + help="\b\nA raw JSON {} query. " + "Useful for when the provided query parameters do not satisfy your requirements." + "\nWARNING: Using advanced queries is incompatible with other query-building args.".format( + search_term + ), + callback=validate_advanced_query_is_json, + ) + checkpoint_option = click.option( + "-c", + "--use-checkpoint", + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Only get {0} that were not previously retrieved.".format(search_term), + ) + + def search_options(f): + f = begin_option(f) + f = end_option(f) + f = checkpoint_option(f) + f = advanced_query_option(f) + return f + + return search_options + + +output_file_arg = click.argument( + "output_file", type=click.Path(dir_okay=False, resolve_path=True, writable=True) +) + + +def server_options(f): + hostname_arg = click.argument("hostname") + protocol_option = click.option( + "-p", + "--protocol", + type=click.Choice(ServerProtocol()), + default=ServerProtocol.UDP, + help="Protocol used to send logs to server.", + ) + f = hostname_arg(f) + f = protocol_option(f) + return f diff --git a/src/code42cli/cmds/search_shared/__init__.py b/src/code42cli/cmds/search_shared/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/code42cli/cmds/search_shared/args.py b/src/code42cli/cmds/search_shared/args.py deleted file mode 100644 index b864d3146..000000000 --- a/src/code42cli/cmds/search_shared/args.py +++ /dev/null @@ -1,85 +0,0 @@ -from code42cli.cmds.search_shared.enums import SearchArguments, OutputFormat, AlertOutputFormat -from code42cli.args import ArgConfig - -SEARCH_FOR_ALERTS = u"alerts" -SEARCH_FOR_FILE_EVENTS = u"file events" - - -def create_search_args(search_for, filter_args): - incompatible_args = create_incompatible_search_args(search_for) - filter_args.update(incompatible_args) - if search_for == SEARCH_FOR_FILE_EVENTS: - filter_args.update(_saved_search_args()) - format_enum = AlertOutputFormat() if search_for == SEARCH_FOR_ALERTS else OutputFormat() - - advanced_query_compatible_args = { - SearchArguments.ADVANCED_QUERY: ArgConfig( - u"--{}".format(SearchArguments.ADVANCED_QUERY.replace(u"_", u"-")), - metavar=u"QUERY_JSON", - help=u"A raw JSON {0} query. " - u"Useful for when the provided query parameters do not satisfy your requirements.\n" - u"WARNING: Using advanced queries is incompatible with other query-building args.".format( - search_for - ), - ), - u"format": ArgConfig( - u"-f", - u"--format", - choices=format_enum, - default=format_enum.JSON, - help=u"The format used for outputting {0}.".format(search_for), - ), - } - filter_args.update(advanced_query_compatible_args) - - return filter_args - - -def _saved_search_args(): - saved_search = ArgConfig( - u"--saved-search", - help=u"Limits events to those discoverable with the saved search " - u"filters for the saved search with the given ID.\n" - u"WARNING: Using saved search is incompatible with other query-building args.", - ) - return {u"saved_search": saved_search} - - -def create_incompatible_search_args(search_for=None): - """Returns a dict of args that are incompatible with the --advanced-query flag. Any new - incompatible args should go here as this is function is also used for arg validation.""" - args = { - SearchArguments.BEGIN_DATE: ArgConfig( - u"-b", - u"--{}".format(SearchArguments.BEGIN_DATE), - metavar=u"DATE", - help=u"The beginning of the date range in which to look for {0}, " - u"can be a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format " - u"where the 'time' portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') " - u"or a short value representing days (30d), hours (24h) or minutes (15m) from current " - u"time.".format(search_for), - ), - SearchArguments.END_DATE: ArgConfig( - u"-e", - u"--{}".format(SearchArguments.END_DATE), - metavar=u"DATE", - help=u"The end of the date range in which to look for {0}, " - u"argument format options are the same as --begin.".format(search_for), - ), - u"use_checkpoint": ArgConfig( - u"-c", - u"--use-checkpoint", - help=u"Only get {0} that were not previously retrieved.".format(search_for), - ), - } - return args - - -def get_advanced_query_incompatible_search_args(search_for): - incompatible_args = create_incompatible_search_args(search_for) - incompatible_args.update(_saved_search_args()) - return incompatible_args - - -def get_saved_search_incompatible_search_args(search_for): - return create_incompatible_search_args(search_for) diff --git a/src/code42cli/cmds/search_shared/enums.py b/src/code42cli/cmds/search_shared/enums.py deleted file mode 100644 index 491954d9f..000000000 --- a/src/code42cli/cmds/search_shared/enums.py +++ /dev/null @@ -1,173 +0,0 @@ -IS_CHECKPOINT_KEY = u"use_checkpoint" - - -class OutputFormat(object): - CEF = u"CEF" - JSON = u"JSON" - RAW = u"RAW-JSON" - - def __iter__(self): - return iter([self.CEF, self.JSON, self.RAW]) - - -class AlertOutputFormat(object): - JSON = u"JSON" - RAW = u"RAW-JSON" - - def __iter__(self): - return iter([self.JSON, self.RAW]) - - -class AlertSeverity(object): - HIGH = u"HIGH" - MEDIUM = u"MEDIUM" - LOW = u"LOW" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [self.HIGH, self.MEDIUM, self.LOW] - - -class AlertState(object): - OPEN = u"OPEN" - DISMISSED = u"RESOLVED" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [self.OPEN, self.DISMISSED] - - -class ExposureType(object): - SHARED_VIA_LINK = u"SharedViaLink" - SHARED_TO_DOMAIN = u"SharedToDomain" - APPLICATION_READ = u"ApplicationRead" - CLOUD_STORAGE = u"CloudStorage" - REMOVABLE_MEDIA = u"RemovableMedia" - IS_PUBLIC = u"IsPublic" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [ - self.SHARED_VIA_LINK, - self.SHARED_TO_DOMAIN, - self.APPLICATION_READ, - self.CLOUD_STORAGE, - self.REMOVABLE_MEDIA, - self.IS_PUBLIC, - ] - - -class RuleType(object): - ENDPOINT_EXFILTRATION = u"FedEndpointExfiltration" - CLOUD_SHARE_PERMISSIONS = u"FedCloudSharePermissions" - FILE_TYPE_MISMATCH = u"FedFileTypeMismatch" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [self.ENDPOINT_EXFILTRATION, self.CLOUD_SHARE_PERMISSIONS, self.FILE_TYPE_MISMATCH] - - -class ServerProtocol(object): - TCP = u"TCP" - UDP = u"UDP" - - def __iter__(self): - return iter([self.TCP, self.UDP]) - - -class SearchArguments(object): - """These string values should match `argparse` stored parameter names. For example, for the - CLI argument `--c42-username`, the string should be `c42_username`.""" - - ADVANCED_QUERY = u"advanced_query" - BEGIN_DATE = u"begin" - END_DATE = u"end" - - def __iter__(self): - return iter([self.ADVANCED_QUERY, self.BEGIN_DATE, self.END_DATE]) - - -class FileEventFilterArguments(SearchArguments): - EXPOSURE_TYPES = u"type" - C42_USERNAME = u"c42_username" - ACTOR = u"actor" - MD5 = u"md5" - SHA256 = u"sha256" - SOURCE = u"source" - FILE_NAME = u"file_name" - FILE_PATH = u"file_path" - PROCESS_OWNER = u"process_owner" - TAB_URL = u"tab_url" - INCLUDE_NON_EXPOSURE_EVENTS = u"include_non_exposure" - - def __iter__(self): - return iter( - [ - self.EXPOSURE_TYPES, - self.C42_USERNAME, - self.ACTOR, - self.MD5, - self.SHA256, - self.SOURCE, - self.FILE_NAME, - self.FILE_PATH, - self.PROCESS_OWNER, - self.TAB_URL, - self.INCLUDE_NON_EXPOSURE_EVENTS, - ] - ) - - -class AlertFilterArguments(object): - STATE = u"state" - SEVERITY = u"severity" - ACTOR = u"actor" - ACTOR_CONTAINS = u"actor_contains" - EXCLUDE_ACTOR = u"exclude_actor" - EXCLUDE_ACTOR_CONTAINS = u"exclude_actor_contains" - RULE_NAME = u"rule_name" - EXCLUDE_RULE_NAME = u"exclude_rule_name" - RULE_ID = u"rule_id" - EXCLUDE_RULE_ID = u"exclude_rule_id" - RULE_TYPE = u"rule_type" - EXCLUDE_RULE_TYPE = u"exclude_rule_type" - DESCRIPTION = u"description" - - def __iter__(self): - return iter( - [ - self.STATE, - self.SEVERITY, - self.ACTOR, - self.ACTOR_CONTAINS, - self.EXCLUDE_ACTOR, - self.EXCLUDE_ACTOR_CONTAINS, - self.RULE_NAME, - self.EXCLUDE_RULE_NAME, - self.RULE_ID, - self.EXCLUDE_RULE_ID, - self.RULE_TYPE, - self.EXCLUDE_RULE_TYPE, - self.DESCRIPTION, - ] - ) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py new file mode 100644 index 000000000..e678a2b4e --- /dev/null +++ b/src/code42cli/cmds/securitydata.py @@ -0,0 +1,305 @@ +from pprint import pformat + +import click +from c42eventextractor.extractors import FileEventExtractor +from click import echo +from py42.sdk.queries.fileevents.filters import * + +import code42cli.errors as errors +from code42cli.cmds.search import logger_factory +from code42cli.cmds.search.cursor_store import FileEventCursorStore +from code42cli.cmds.search.enums import ( + OutputFormat, + ExposureType as ExposureTypeOptions, +) +from code42cli.cmds.search.extraction import ( + create_handlers, + create_time_range_filter, +) +from code42cli.cmds.search.options import ( + create_search_options, + AdvancedQueryAndSavedSearchIncompatible, + is_in_filter, + exists_filter, + output_file_arg, + server_options, +) +from code42cli.logger import get_main_cli_logger +from code42cli.options import sdk_options, incompatible_with, OrderedGroup +from code42cli.util import format_to_table, find_format_width + +logger = get_main_cli_logger() + +search_options = create_search_options("file events") + +format_option = click.option( + "-f", + "--format", + type=click.Choice(OutputFormat()), + default=OutputFormat.JSON, + help="The format used for outputting file events.", +) +exposure_type_option = click.option( + "-t", + "--type", + multiple=True, + type=click.Choice(list(ExposureTypeOptions())), + cls=AdvancedQueryAndSavedSearchIncompatible, + callback=is_in_filter(ExposureType), + help="Limits events to those with given exposure types.", +) +username_option = click.option( + "--c42-username", + multiple=True, + callback=is_in_filter(DeviceUsername), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to endpoint events for these users.", +) +actor_option = click.option( + "--actor", + multiple=True, + callback=is_in_filter(Actor), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to only those enacted by the cloud service user " + "of the person who caused the event.", +) +md5_option = click.option( + "--md5", + multiple=True, + callback=is_in_filter(MD5), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to file events where the file has one of these MD5 hashes.", +) +sha256_option = click.option( + "--sha256", + multiple=True, + callback=is_in_filter(SHA256), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to file events where the file has one of these SHA256 hashes.", +) +source_option = click.option( + "--source", + multiple=True, + callback=is_in_filter(Source), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to only those from one of these sources. Example=Gmail.", +) +file_name_option = click.option( + "--file-name", + multiple=True, + callback=is_in_filter(FileName), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to file events where the file has one of these names.", +) +file_path_option = click.option( + "--file-path", + multiple=True, + callback=is_in_filter(FilePath), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to file events where the file is located at one of these paths.", +) +process_owner_option = click.option( + "--process-owner", + multiple=True, + callback=is_in_filter(ProcessOwner), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to exposure events where one of these users owns " + "the process behind the exposure.", +) +tab_url_option = click.option( + "--tab-url", + multiple=True, + callback=is_in_filter(TabURL), + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to be exposure events with one of these destination tab URLs.", +) +include_non_exposure_option = click.option( + "--include-non-exposure", + is_flag=True, + callback=exists_filter(ExposureType), + cls=incompatible_with(["advanced_query", "type", "saved_search"]), + help="Get all events including non-exposure events.", +) + + +def _get_saved_search_query(ctx, param, arg): + if arg is None: + return + query = ctx.obj.sdk.securitydata.savedsearches.get_query(arg) + return query + + +saved_search_option = click.option( + "--saved-search", + help="Get events from a saved search filter with the given ID", + callback=_get_saved_search_query, + cls=incompatible_with("advanced_query"), +) + + +def file_event_options(f): + f = exposure_type_option(f) + f = username_option(f) + f = actor_option(f) + f = md5_option(f) + f = sha256_option(f) + f = source_option(f) + f = file_name_option(f) + f = file_path_option(f) + f = process_owner_option(f) + f = tab_url_option(f) + f = include_non_exposure_option(f) + f = format_option(f) + f = saved_search_option(f) + return f + + +@click.group(cls=OrderedGroup) +@sdk_options +def security_data(state): + """Tools for getting security related data, such as file events.""" + # store cursor getter on the group state so shared --begin option can use it in validation + state.cursor_getter = _get_file_event_cursor_store + + +@security_data.command() +@click.argument("checkpoint-name") +@sdk_options +def clear_checkpoint(state, checkpoint_name): + """Remove the saved file event checkpoint from '--use-checkpoint/-c' mode.""" + _get_file_event_cursor_store(state.profile.name).delete(checkpoint_name) + + +@security_data.command("print") +@file_event_options +@search_options +@sdk_options +def _print(state, format, begin, end, advanced_query, use_checkpoint, saved_search, **kwargs): + """Print file events to stdout.""" + output_logger = logger_factory.get_logger_for_stdout(format) + cursor = _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None + _extract( + sdk=state.sdk, + cursor=cursor, + checkpoint_name=use_checkpoint, + filter_list=state.search_filters, + begin=begin, + end=end, + advanced_query=advanced_query, + saved_search=saved_search, + output_logger=output_logger, + ) + + +@security_data.command() +@output_file_arg +@file_event_options +@search_options +@sdk_options +def write_to( + state, format, output_file, begin, end, advanced_query, use_checkpoint, saved_search, **kwargs +): + """Write file events to the file with the given name.""" + output_logger = logger_factory.get_logger_for_file(output_file, format) + cursor = _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None + _extract( + sdk=state.sdk, + cursor=cursor, + checkpoint_name=use_checkpoint, + filter_list=state.search_filters, + begin=begin, + end=end, + advanced_query=advanced_query, + saved_search=saved_search, + output_logger=output_logger, + ) + + +@security_data.command() +@server_options +@file_event_options +@search_options +@sdk_options +def send_to( + state, + format, + hostname, + protocol, + begin, + end, + advanced_query, + use_checkpoint, + saved_search, + **kwargs +): + """Send file events to the given server address.""" + output_logger = logger_factory.get_logger_for_server(hostname, protocol, format) + cursor = _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None + _extract( + sdk=state.sdk, + cursor=cursor, + checkpoint_name=use_checkpoint, + filter_list=state.search_filters, + begin=begin, + end=end, + advanced_query=advanced_query, + saved_search=saved_search, + output_logger=output_logger, + ) + + +@security_data.group(cls=OrderedGroup) +@sdk_options +def saved_search(state): + pass + + +@saved_search.command("list") +@sdk_options +def _list(state): + """List available saved searches.""" + response = state.sdk.securitydata.savedsearches.get() + header = {"name": "Name", "id": "Id"} + format_to_table(*find_format_width(response["searches"], header)) + + +@saved_search.command() +@click.argument("search-id") +@sdk_options +def show(state, search_id): + """Get the details of a saved search.""" + response = state.sdk.securitydata.savedsearches.get_by_id(search_id) + echo(pformat(response["searches"])) + + +def _extract( + sdk, + cursor, + checkpoint_name, + filter_list, + begin, + end, + advanced_query, + saved_search, + output_logger, +): + handlers = create_handlers(sdk, FileEventExtractor, output_logger, cursor, checkpoint_name) + extractor = _get_file_event_extractor(sdk, handlers) + if advanced_query: + extractor.extract_advanced(advanced_query) + elif saved_search: + extractor.extract(*saved_search._filter_group_list) + else: + if begin or end: + filter_list.append(create_time_range_filter(EventTimestamp, begin, end)) + extractor.extract(*filter_list) + if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: + echo("No results found.") + + +def _get_file_event_extractor(sdk, handlers): + return FileEventExtractor(sdk, handlers) + + +def _get_file_event_cursor_store(profile_name): + return FileEventCursorStore(profile_name) diff --git a/src/code42cli/cmds/securitydata/__init__.py b/src/code42cli/cmds/securitydata/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/code42cli/cmds/securitydata/extraction.py b/src/code42cli/cmds/securitydata/extraction.py deleted file mode 100644 index a311fe5fe..000000000 --- a/src/code42cli/cmds/securitydata/extraction.py +++ /dev/null @@ -1,88 +0,0 @@ -from c42eventextractor.extractors import FileEventExtractor -from py42.sdk.queries.fileevents.filters import * - -from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions -from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore -from code42cli.cmds.search_shared.extraction import ( - verify_begin_date_requirements, - create_handlers, - create_time_range_filter, -) -import code42cli.errors as errors -from code42cli.logger import get_main_cli_logger - -logger = get_main_cli_logger() - - -def extract(sdk, profile, output_logger, args, query=None): - """Extracts file events using the given command-line arguments. - - Args: - sdk (py42.sdk.SDKClient): The py42 sdk. - profile (Code42Profile): The profile under which to execute this command. - output_logger (Logger): The logger specified by which subcommand you use. For example, - print: uses a logger that streams to stdout. - write-to: uses a logger that logs to a file. - send-to: uses a logger that sends logs to a server. - args: Command line args used to build up file event query filters. - query: FileEventQuery instance created from search-id of saved search. - """ - store = FileEventCursorStore(profile.name) if args.use_checkpoint else None - handlers = create_handlers(sdk, FileEventExtractor, output_logger, store, args.use_checkpoint) - extractor = FileEventExtractor(sdk, handlers) - if not args.advanced_query and not args.saved_search: - verify_begin_date_requirements(args, store) - if args.type: - _verify_exposure_types(args.type) - if args.advanced_query: - extractor.extract_advanced(args.advanced_query) - else: - filters = _create_file_event_filters(args) - if args.saved_search: - filters.extend(query._filter_group_list) - extractor.extract(*filters) - if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: - logger.print_info(u"No results found.") - - -def _verify_exposure_types(exposure_types): - if exposure_types is None: - return - options = list(ExposureTypeOptions()) - for exposure_type in exposure_types: - if exposure_type not in options: - logger.print_and_log_error(u"'{0}' is not a valid exposure type.".format(exposure_type)) - exit(1) - - -def _create_file_event_filters(args): - filters = [] - event_timestamp_filter = create_time_range_filter(EventTimestamp, args.begin, args.end) - not event_timestamp_filter or filters.append(event_timestamp_filter) - not args.c42_username or filters.append(DeviceUsername.is_in(args.c42_username)) - not args.actor or filters.append(Actor.is_in(args.actor)) - not args.md5 or filters.append(MD5.is_in(args.md5)) - not args.sha256 or filters.append(SHA256.is_in(args.sha256)) - not args.source or filters.append(Source.is_in(args.source)) - not args.file_name or filters.append(FileName.is_in(args.file_name)) - not args.file_path or filters.append(FilePath.is_in(args.file_path)) - not args.process_owner or filters.append(ProcessOwner.is_in(args.process_owner)) - not args.tab_url or filters.append(TabURL.is_in(args.tab_url)) - _try_append_exposure_types_filter(filters, args.include_non_exposure, args.type) - return filters - - -def _try_append_exposure_types_filter(filters, include_non_exposure_events, exposure_types): - _exposure_filter = _create_exposure_type_filter(include_non_exposure_events, exposure_types) - if _exposure_filter: - filters.append(_exposure_filter) - - -def _create_exposure_type_filter(include_non_exposure_events, exposure_types): - if include_non_exposure_events and exposure_types: - logger.print_and_log_error(u"Cannot use exposure types with `--include-non-exposure`.") - exit(1) - if exposure_types: - return ExposureType.is_in(exposure_types) - if not include_non_exposure_events: - return ExposureType.exists() diff --git a/src/code42cli/cmds/securitydata/main.py b/src/code42cli/cmds/securitydata/main.py deleted file mode 100644 index 1619dadd9..000000000 --- a/src/code42cli/cmds/securitydata/main.py +++ /dev/null @@ -1,219 +0,0 @@ -from code42cli.args import ArgConfig -from code42cli.parser import exit_if_mutually_exclusive_args_used_together -from code42cli.cmds.search_shared import logger_factory, args -from code42cli.cmds.search_shared.enums import ( - FileEventFilterArguments, - ServerProtocol, - ExposureType, -) -from code42cli.cmds.securitydata.extraction import extract -from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore -from code42cli.commands import Command, SubcommandLoader -from code42cli.cmds.securitydata.savedsearch.commands import SavedSearchSubCommandLoader -from code42cli.cmds.search_shared.args import ( - SEARCH_FOR_FILE_EVENTS, - get_advanced_query_incompatible_search_args, - get_saved_search_incompatible_search_args, -) - - -class SecurityDataSubcommandLoader(SubcommandLoader): - PRINT = u"print" - WRITE_TO = u"write-to" - SEND_TO = u"send-to" - CLEAR_CHECKPOINT = u"clear-checkpoint" - SAVED_SEARCH = u"saved-search" - - def load_commands(self): - """Sets up the `security-data` subcommand with all of its subcommands.""" - usage_prefix = u"code42 security-data" - - print_func = Command( - self.PRINT, - u"Print file events to stdout.", - u"{} {}".format(usage_prefix, u"print "), - handler=print_out, - arg_customizer=_load_search_args, - use_single_arg_obj=True, - ) - - write = Command( - self.WRITE_TO, - u"Write file events to the file with the given name.", - u"{} {}".format(usage_prefix, u"write-to "), - handler=write_to, - arg_customizer=_load_write_to_args, - use_single_arg_obj=True, - ) - - send = Command( - self.SEND_TO, - u"Send file events to the given server address.", - u"{} {}".format(usage_prefix, u"send-to "), - handler=send_to, - arg_customizer=_load_send_to_args, - use_single_arg_obj=True, - ) - - clear = Command( - self.CLEAR_CHECKPOINT, - u"Remove the saved file event checkpoint from 'use-checkpoint' (-c) mode.", - u"{} {}".format(usage_prefix, u"clear-checkpoint "), - handler=clear_checkpoint, - ) - - saved_search = Command( - self.SAVED_SEARCH, - u"Manage saved searches.", - subcommand_loader=SavedSearchSubCommandLoader(self.SAVED_SEARCH), - ) - - return [print_func, write, send, clear, saved_search] - - -def clear_checkpoint(sdk, profile, cursor_name): - """Removes the stored checkpoint that keeps track of the last file event retrieved for the given profile. - To use, run `code42 security-data clear-checkpoint`. - This affects `use-checkpoint` mode by resetting the checkpoint, causing it to behave like it has never been run before. - """ - FileEventCursorStore(profile.name).delete(cursor_name) - - -def _get_incompatible_search_args(incompatible_search_args_dict): - incompatible_search_args_list = list(incompatible_search_args_dict.keys()) - return incompatible_search_args_list + list(FileEventFilterArguments()) - - -def _validate_args(args): - - if args.advanced_query: - incompatible_search_args_dict = get_advanced_query_incompatible_search_args( - SEARCH_FOR_FILE_EVENTS - ) - incompatible_search_args = _get_incompatible_search_args(incompatible_search_args_dict) - exit_if_mutually_exclusive_args_used_together(args, incompatible_search_args) - if args.saved_search: - incompatible_search_args_dict = get_saved_search_incompatible_search_args( - SEARCH_FOR_FILE_EVENTS - ) - incompatible_search_args = _get_incompatible_search_args(incompatible_search_args_dict) - exit_if_mutually_exclusive_args_used_together( - args, incompatible_search_args, u"--saved-search" - ) - - -def _extract(sdk, profile, logger, args): - query = ( - sdk.securitydata.savedsearches.get_query(args.saved_search) if args.saved_search else None - ) - extract(sdk, profile, logger, args, query) - - -def print_out(sdk, profile, args): - """Activates 'print' command. It gets security events and prints them to stdout.""" - _validate_args(args) - logger = logger_factory.get_logger_for_stdout(args.format) - _extract(sdk, profile, logger, args) - - -def write_to(sdk, profile, args): - """Activates 'write-to' command. It gets security events and writes them to the given file.""" - _validate_args(args) - logger = logger_factory.get_logger_for_file(args.output_file, args.format) - _extract(sdk, profile, logger, args) - - -def send_to(sdk, profile, args): - """Activates 'send-to' command. It gets security events and logs them to the given server.""" - _validate_args(args) - logger = logger_factory.get_logger_for_server(args.server, args.protocol, args.format) - _extract(sdk, profile, logger, args) - - -def _load_write_to_args(arg_collection): - output_file = ArgConfig(u"output_file", help=u"The name of the local file to send output to.") - arg_collection.append(u"output_file", output_file) - _load_search_args(arg_collection) - - -def _load_send_to_args(arg_collection): - send_to_args = { - u"server": ArgConfig(u"server", help=u"The server address to send output to."), - u"protocol": ArgConfig( - u"-p", - u"--protocol", - choices=ServerProtocol(), - default=ServerProtocol.UDP, - help=u"Protocol used to send logs to server.", - ), - } - arg_collection.extend(send_to_args) - _load_search_args(arg_collection) - - -def _load_search_args(arg_collection): - filter_args = { - FileEventFilterArguments.EXPOSURE_TYPES: ArgConfig( - u"-t", - u"--{}".format(FileEventFilterArguments.EXPOSURE_TYPES), - nargs=u"+", - help=u"Limits events to those with given exposure types. " - u"Available choices={0}".format(list(ExposureType())), - ), - FileEventFilterArguments.C42_USERNAME: ArgConfig( - u"--{}".format(FileEventFilterArguments.C42_USERNAME.replace(u"_", u"-")), - nargs=u"+", - help=u"Limits events to endpoint events for these users.", - ), - FileEventFilterArguments.ACTOR: ArgConfig( - u"--{}".format(FileEventFilterArguments.ACTOR), - nargs=u"+", - help=u"Limits events to only those enacted by the cloud service user of the person who caused the event.", - ), - FileEventFilterArguments.MD5: ArgConfig( - u"--{}".format(FileEventFilterArguments.MD5), - nargs=u"+", - help=u"Limits events to file events where the file has one of these MD5 hashes.", - ), - FileEventFilterArguments.SHA256: ArgConfig( - u"--{}".format(FileEventFilterArguments.SHA256), - nargs=u"+", - action=u"store", - help=u"Limits events to file events where the file has one of these SHA256 hashes.", - ), - FileEventFilterArguments.SOURCE: ArgConfig( - u"--{}".format(FileEventFilterArguments.SOURCE), - nargs=u"+", - help=u"Limits events to only those from one of these sources. Example=Gmail.", - ), - FileEventFilterArguments.FILE_NAME: ArgConfig( - u"--{}".format(FileEventFilterArguments.FILE_NAME.replace(u"_", u"-")), - nargs=u"+", - help=u"Limits events to file events where the file has one of these names.", - ), - FileEventFilterArguments.FILE_PATH: ArgConfig( - u"--{}".format(FileEventFilterArguments.FILE_PATH.replace(u"_", u"-")), - nargs=u"+", - help=u"Limits events to file events where the file is located at one of these paths.", - ), - FileEventFilterArguments.PROCESS_OWNER: ArgConfig( - u"--{}".format(FileEventFilterArguments.PROCESS_OWNER.replace(u"_", u"-")), - nargs=u"+", - help=u"Limits events to exposure events where one of these users " - u"owns the process behind the exposure.", - ), - FileEventFilterArguments.TAB_URL: ArgConfig( - u"--{}".format(FileEventFilterArguments.TAB_URL.replace(u"_", u"-")), - nargs=u"+", - help=u"Limits events to be exposure events with one of these destination tab URLs.", - ), - FileEventFilterArguments.INCLUDE_NON_EXPOSURE_EVENTS: ArgConfig( - u"--include-non-exposure", - action=u"store_true", - help=u"Get all events including non-exposure events.", - ), - } - search_args = args.create_search_args( - search_for=SEARCH_FOR_FILE_EVENTS, filter_args=filter_args - ) - arg_collection.extend(search_args) diff --git a/src/code42cli/cmds/securitydata/savedsearch/__init__.py b/src/code42cli/cmds/securitydata/savedsearch/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/code42cli/cmds/securitydata/savedsearch/commands.py b/src/code42cli/cmds/securitydata/savedsearch/commands.py deleted file mode 100644 index bfdb368aa..000000000 --- a/src/code42cli/cmds/securitydata/savedsearch/commands.py +++ /dev/null @@ -1,33 +0,0 @@ -from code42cli.commands import Command, SubcommandLoader -from code42cli.cmds.securitydata.savedsearch.savedsearch import show, show_detail - - -def _load_search_id_args(argument_collection): - search_id = argument_collection.arg_configs[u"search_id"] - search_id.set_help(u"The id of the saved search.") - - -class SavedSearchSubCommandLoader(SubcommandLoader): - LIST = u"list" - SHOW = u"show" - - def load_commands(self): - """Sets up security-data subcommand with all of its subcommands.""" - usage_prefix = u"code42 security-data saved-search" - - list_command = Command( - self.LIST, - u"List available saved searches.", - u"{} {}".format(usage_prefix, self.LIST), - handler=show, - ) - - show_command = Command( - self.SHOW, - "Get the details of a saved search.", - u"{} {} {}".format(usage_prefix, self.SHOW, u""), - handler=show_detail, - arg_customizer=_load_search_id_args, - ) - - return [list_command, show_command] diff --git a/src/code42cli/cmds/securitydata/savedsearch/savedsearch.py b/src/code42cli/cmds/securitydata/savedsearch/savedsearch.py deleted file mode 100644 index 08e028753..000000000 --- a/src/code42cli/cmds/securitydata/savedsearch/savedsearch.py +++ /dev/null @@ -1,15 +0,0 @@ -from pprint import pprint - -from code42cli.util import format_to_table, find_format_width - - -def show(sdk, profile): - response = sdk.securitydata.savedsearches.get() - header = {u"name": u"Name", u"id": u"Id"} - return format_to_table(*find_format_width(response[u"searches"], header)) - - -def show_detail(sdk, profile, search_id): - response = sdk.securitydata.savedsearches.get_by_id(search_id) - pprint(response["searches"]) - diff --git a/src/code42cli/cmds/shared.py b/src/code42cli/cmds/shared.py new file mode 100644 index 000000000..503d5075e --- /dev/null +++ b/src/code42cli/cmds/shared.py @@ -0,0 +1,21 @@ +from functools import lru_cache + +from code42cli.errors import UserDoesNotExistError + + +@lru_cache(maxsize=None) +def get_user_id(sdk, username): + """Returns the user's UID (referred to by `user_id` in detection lists). Raises + `UserDoesNotExistError` if the user doesn't exist in the Code42 server. + + Args: + sdk (py42.sdk.SDKClient): The py42 sdk. + username (str or unicode): The username of the user to get an ID for. + + Returns: + str: The user ID for the user with the given username. + """ + users = sdk.users.get_by_username(username)["users"] + if not users: + raise UserDoesNotExistError(username) + return users[0]["userUid"] diff --git a/src/code42cli/commands.py b/src/code42cli/commands.py deleted file mode 100644 index 3edc6bfdb..000000000 --- a/src/code42cli/commands.py +++ /dev/null @@ -1,174 +0,0 @@ -import inspect - -from code42cli import profile as cliprofile -from code42cli.args import get_auto_arg_configs, SDK_ARG_NAME, PROFILE_ARG_NAME -from code42cli.sdk_client import create_sdk -from code42cli.tree_nodes import SubcommandNode - - -class DictObject(object): - def __init__(self, _dict): - self.__dict__ = _dict - - -class Command(object): - """Represents a function that a CLI user can execute. Add a command to - `code42cli.main._load_top_commands` or as a subcommand of one those - commands to make it available for use. - - Args: - name (str or unicode): The name of the command. For example, in - `code42 profile show`, "show" is the name, while "profile" - is the name of the parent command. - - description (str or unicode): Descriptive text to be displayed when using -h. - - usage (str or unicode, optional): A usage example to be displayed when using -h. - handler (function, optional): The function to be exectued when the command is run. - - arg_customizer (function, optional): A function accepting a single `ArgCollection` - parameter that allows for editing the collection when `get_arg_configs` is run. - - subcommand_loader (SubcommandLoader, optional): An object that can load subcommands - for this command. - - use_single_arg_obj (bool, optional): When True, causes all parameters sent to - `__call__` to be consolidated in an object with attribute names dictated - by the parameter names. That object is passed to `handler`'s `arg` parameter. - """ - - def __init__( - self, - name, - description, - usage=None, - handler=None, - arg_customizer=None, - subcommand_loader=None, - use_single_arg_obj=None, - ): - self._name = name - self._description = description - self._usage = usage - self._handler = handler - self._arg_customizer = arg_customizer - self._subcommand_loader = subcommand_loader - self._use_single_arg_obj = use_single_arg_obj - self._subcommands = [] - - def __call__(self, *args, **kwargs): - """Passes the parsed argparse args to the handler, or - shows the help of for this command if there is no handler - (common in commands that are simply groups of subcommands). - """ - if callable(self._handler): - kvps = _get_arg_kvps(args[0], self._handler) - if self._use_single_arg_obj: - kvps = _kvps_to_obj(kvps) - return self._handler(**kvps) - help_func = kwargs.pop(u"help_func", None) - if help_func: - return help_func() - - @property - def name(self): - return self._name - - @property - def description(self): - return self._description - - @property - def usage(self): - return self._usage - - @property - def subcommands(self): - return self._subcommands - - @property - def subcommand_loader(self): - return self._subcommand_loader - - def load_subcommands(self): - self._subcommands = ( - self._subcommand_loader.load_commands() if self._subcommand_loader else [] - ) - return self._subcommands - - def get_arg_configs(self): - """Returns a collection of argparse configurations based on - the parameter names of `handler` and any user customizations.""" - arg_config_collection = get_auto_arg_configs(self._handler) - if callable(self._arg_customizer): - self._arg_customizer(arg_config_collection) - - return arg_config_collection.arg_configs - - -def _get_arg_kvps(parsed_args, handler): - # transform parsed args from `argparse` into a dict - parsed_args = vars(parsed_args) - kvps = {key.replace(u"-", u"_"): val for (key, val) in parsed_args.items()} - kvps.pop(u"func", None) - return _inject_params(kvps, handler) - - -def _inject_params(kvps, handler): - """Automatically populates parameters named "sdk" or "profile" with instances of the sdk and - profile, respectively. - - Args: - kvps (dict): A dictionary of the parsed command line arguments. - handler (callable): The function or command responsible for processing the command line - arguments. - - Returns: - dict: The dictionary of parsed command line arguments with possibly additional populated - fields. - """ - if _handler_has_arg(SDK_ARG_NAME, handler): - profile_name = kvps.pop(PROFILE_ARG_NAME, None) - debug = kvps.pop(u"debug", None) - - profile = cliprofile.get_profile(profile_name) - kvps[SDK_ARG_NAME] = create_sdk(profile, debug) - - if _handler_has_arg(PROFILE_ARG_NAME, handler): - kvps[PROFILE_ARG_NAME] = profile - return kvps - - -def _handler_has_arg(arg_name, handler): - argspec = inspect.getargspec(handler) - return arg_name in argspec.args - - -def _kvps_to_obj(kvps): - new_kvps = {key: kvps[key] for key in kvps if key in [SDK_ARG_NAME, PROFILE_ARG_NAME]} - new_kvps[u"args"] = DictObject(kvps) - return new_kvps - - -class SubcommandLoader(object): - """Responsible for creating subcommands for it's root command.""" - - def __init__(self, root_command_name, node=None): - self.root = root_command_name - self._node = node - - def __getitem__(self, item): - return self.get_node()[item] - - @property - def names(self): - return self.get_node().names - - def load_commands(self): - """Override""" - return [] - - def get_node(self): - if not self._node: - self._node = SubcommandNode(self.root, self.load_commands()) - return self._node diff --git a/src/code42cli/compat.py b/src/code42cli/compat.py deleted file mode 100644 index 69ce05589..000000000 --- a/src/code42cli/compat.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -This module handles import compatibility issues between Python 2 and -Python 3. -""" -# pylint: disable=undefined-variable,import-error,unused-import,no-name-in-module - -import sys - -_ver = sys.version_info - -#: Python 2.x? -is_py2 = _ver[0] == 2 - -if is_py2: - from urlparse import urljoin, urlparse - - def _str(obj): - return unicode(obj) - - str = _str - - import io - - open = io.open - - import repr as reprlib - - import Queue as queue - - range = xrange -else: - from urllib.parse import urljoin, urlparse - - str = str - open = open - - import reprlib - - import queue - - range = range diff --git a/src/code42cli/completer.py b/src/code42cli/completer.py deleted file mode 100644 index f4ac3f8be..000000000 --- a/src/code42cli/completer.py +++ /dev/null @@ -1,81 +0,0 @@ -from os import path - -from code42cli import MAIN_COMMAND -from code42cli.main import MainSubcommandLoader -from code42cli.tree_nodes import ArgNode -from code42cli.util import get_files_in_path - - -def _get_matches(current, options): - matches = [] - current = current.strip() - for opt in options: - if opt.startswith(current) and opt != current: - matches.append(opt) - return matches - - -def _get_next_full_set_of_options(node, current): - node = node[current] - names = list(node.names) - if _can_complete_with_local_files(current, node): - files = get_files_in_path("") - names.extend(files) - return names - - -def _can_complete_with_local_files(current, node): - return isinstance(node, ArgNode) and (not current or current[0] != u"-") - - -class Completer(object): - def __init__(self, main_cmd_loader=None): - self._main_cmd_loader = main_cmd_loader or MainSubcommandLoader() - - def complete(self, cmdline, point=None): - try: - point = point or len(cmdline) - args = cmdline[0:point].split() - # Complete with main commands if `code42` is typed out. - # Note that the command `code42` should complete on its own. - if len(args) < 2: - return self._main_cmd_loader.names if args[0] == MAIN_COMMAND else [] - - current = args[-1] - search_results, options = self._get_completion_options(args) - - # Complete with full set of arg/command options - if current in options: - return _get_next_full_set_of_options(search_results, current) - - if _can_complete_with_local_files(current, search_results): - files = get_files_in_path(current) - if current[0] == "~": - replace = path.expanduser("~") - files = [f.replace(replace, "~") for f in files] - options.extend(files) - - return _get_matches(current, options) if options else [] - except: - return [] - - def _search_trees(self, args): - # Find cmd_loader at lowest level from given args - node = self._main_cmd_loader.get_node() - if len(args) > 2: - for arg in args[1:-1]: - next_node = node[arg] - if next_node: - node = next_node - else: - return node - return node - - def _get_completion_options(self, args): - search_results = self._search_trees(args) - return search_results, search_results.names - - -def complete(cmdline, point): - choices = Completer().complete(cmdline, point) or [] - print(u" \n".join(choices)) diff --git a/src/code42cli/config.py b/src/code42cli/config.py index 476685cad..c63a02c5c 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -1,33 +1,32 @@ import os - from configparser import ConfigParser +from click import echo + import code42cli.util as util -from code42cli.compat import str -from code42cli.logger import get_main_cli_logger class NoConfigProfileError(Exception): def __init__(self, profile_arg_name=None): message = ( - u"Profile '{}' does not exist.".format(profile_arg_name) + "Profile '{}' does not exist.".format(profile_arg_name) if profile_arg_name - else u"Profile does not exist." + else "Profile does not exist." ) super(NoConfigProfileError, self).__init__(message) class ConfigAccessor(object): - DEFAULT_VALUE = u"__DEFAULT__" - AUTHORITY_KEY = u"c42_authority_url" - USERNAME_KEY = u"c42_username" - IGNORE_SSL_ERRORS_KEY = u"ignore-ssl-errors" - DEFAULT_PROFILE = u"default_profile" - _INTERNAL_SECTION = u"Internal" + DEFAULT_VALUE = "__DEFAULT__" + AUTHORITY_KEY = "c42_authority_url" + USERNAME_KEY = "c42_username" + IGNORE_SSL_ERRORS_KEY = "ignore-ssl-errors" + DEFAULT_PROFILE = "default_profile" + _INTERNAL_SECTION = "Internal" def __init__(self, parser): self.parser = parser - file_name = u"config.cfg" + file_name = "config.cfg" self.path = os.path.join(util.get_user_project_path(), file_name) if not os.path.exists(self.path): self._create_internal_section() @@ -84,9 +83,7 @@ def switch_default_profile(self, new_default_name): raise NoConfigProfileError(new_default_name) self._internal[self.DEFAULT_PROFILE] = new_default_name self._save() - get_main_cli_logger().print_info( - u"{} has been set as the default profile.".format(new_default_name) - ) + echo("{} has been set as the default profile.".format(new_default_name)) def delete_profile(self, name): """Deletes a profile.""" @@ -138,7 +135,8 @@ def _create_profile_section(self, name): self.parser[name][self.IGNORE_SSL_ERRORS_KEY] = str(False) def _save(self): - util.open_file(self.path, u"w+", lambda file: self.parser.write(file)) + with open(self.path, "w+", encoding="utf-8") as file: + self.parser.write(file) def _try_complete_setup(self, profile): authority = profile.get(self.AUTHORITY_KEY) @@ -151,7 +149,7 @@ def _try_complete_setup(self, profile): return self._save() - get_main_cli_logger().print_info(u"Successfully saved profile '{}'.".format(profile.name)) + echo("Successfully saved profile '{}'.".format(profile.name)) default_profile = self._internal.get(self.DEFAULT_PROFILE) if default_profile is None or default_profile == self.DEFAULT_VALUE: diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py index 10c651f7f..44841ee8a 100644 --- a/src/code42cli/date_helper.py +++ b/src/code42cli/date_helper.py @@ -1,32 +1,41 @@ -from datetime import datetime, timedelta import re +from datetime import datetime, timedelta +import click from c42eventextractor.common import convert_datetime_to_timestamp -from code42cli.errors import DateArgumentError +TIMESTAMP_REGEX = re.compile(r"(\d{4}-\d{2}-\d{2})\s*(.*)?") +MAGIC_TIME_REGEX = re.compile(r"(\d+)([dhm])$") -TIMESTAMP_REGEX = re.compile(u"(\d{4}-\d{2}-\d{2})\s*(.*)?") -MAGIC_TIME_REGEX = re.compile(u"(\d+)([dhm])$") +_FORMAT_VALUE_ERROR_MESSAGE = ( + "input must be a date/time string (e.g. 'yyyy-MM-dd', " + "'yy-MM-dd HH:MM', 'yy-MM-dd HH:MM:SS'), or a short value in days, " + "hours, or minutes (e.g. 30d, 24h, 15m)" +) def verify_timestamp_order(min_timestamp, max_timestamp): if min_timestamp is None or max_timestamp is None: return if min_timestamp >= max_timestamp: - raise DateArgumentError(u"Begin date cannot be after end date") + raise click.BadParameter( + param_hint=["-b", "--begin"], message="cannot be after --end date." + ) def parse_min_timestamp(begin_date_str, max_days_back=90): + if begin_date_str is None: + return dt = _parse_timestamp(begin_date_str, _round_datetime_to_day_start) - boundary_date = _round_datetime_to_day_start(datetime.utcnow() - timedelta(days=max_days_back)) if dt < boundary_date: - raise DateArgumentError(u"'Begin date' must be within {0} days.".format(max_days_back)) - + raise click.BadParameter(message="must be within {0} days.".format(max_days_back)) return convert_datetime_to_timestamp(dt) def parse_max_timestamp(end_date_str): + if end_date_str is None: + return dt = _parse_timestamp(end_date_str, _round_datetime_to_day_end) return convert_datetime_to_timestamp(dt) @@ -44,39 +53,39 @@ def _parse_timestamp(date_str, rounding_func): elif magic_match: num, period = magic_match.groups() dt = _get_dt_from_magic_time_pair(num, period) - if period == u"d": + if period == "d": dt = rounding_func(dt) else: - raise DateArgumentError() + raise click.BadParameter(message=_FORMAT_VALUE_ERROR_MESSAGE) return dt def _get_dt_from_date_time_pair(date, time): - date_format = u"%Y-%m-%d %H:%M:%S" + date_format = "%Y-%m-%d %H:%M:%S" if time: - time = u"{}:{}:{}".format(*time.split(":") + [u"00", u"00"]) + time = "{}:{}:{}".format(*time.split(":") + ["00", "00"]) else: - time = u"00:00:00" - date_string = u"{} {}".format(date, time) + time = "00:00:00" + date_string = "{} {}".format(date, time) try: dt = datetime.strptime(date_string, date_format) except ValueError: - raise DateArgumentError() + raise click.ClickException("Unable to parse date string.") else: return dt def _get_dt_from_magic_time_pair(num, period): num = int(num) - if period == u"d": + if period == "d": dt = datetime.utcnow() - timedelta(days=num) - elif period == u"h": + elif period == "h": dt = datetime.utcnow() - timedelta(hours=num) - elif period == u"m": + elif period == "m": dt = datetime.utcnow() - timedelta(minutes=num) else: - raise DateArgumentError(u"Couldn't parse magic time string: {}{}".format(num, period)) + raise click.ClickException("Couldn't parse magic time string: {}{}".format(num, period)) return dt diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index 189d9a2c6..472c6ddfc 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -1,47 +1,64 @@ -ERRORED = False - - -_FORMAT_VALUE_ERROR_MESSAGE = ( - u"input must be a date/time string (e.g. 'yyyy-MM-dd', " - u"'yy-MM-dd HH:MM', 'yy-MM-dd HH:MM:SS'), or a short value in days, " - u"hours, or minutes (e.g. 30d, 24h, 15m)" -) - - -class BadFileError(Exception): - def __init__(self, file_path, *args, **kwargs): - self.file_path = file_path - super(BadFileError, self).__init__() - +import difflib +import re -class EmptyFileError(BadFileError): - def __init__(self, file_path): - super(EmptyFileError, self).__init__(file_path, u"Given empty file {}.".format(file_path)) +import click +from click._compat import get_text_stderr +from py42.exceptions import Py42ForbiddenError, Py42HTTPError +from code42cli.logger import get_view_error_details_message, get_main_cli_logger -class Code42CLIError(Exception): - pass +ERRORED = False +_DIFFLIB_CUT_OFF = 0.6 + + +class Code42CLIError(click.ClickException): + """Base CLI exception. The `message` param automatically gets logged to error file and printed + to stderr in red text. If `help` param is provided, it will also be printed to stderr after the + message but not logged to file. + """ + + def __init__(self, message, help=None): + self.help = help + super().__init__(message) + + def show(self, file=None): + """Override default `show` to print CLI errors in red text.""" + if file is None: + file = get_text_stderr() + click.secho("Error: {}".format(self.format_message()), file=file, fg="red") + if self.help: + click.echo(self.help, err=True) + + +class LoggedCLIError(Code42CLIError): + """Exception to be raised when wanting to point users to error logs for error details. + + If `message` param is provided it will be printed to screen along with message on where to + find error details in the log. + """ + + def __init__(self, message=None): + self.message = message + super().__init__(message) + + def format_message(self): + locations_message = get_view_error_details_message() + return ( + "{}\n{}".format(self.message, locations_message) if self.message else locations_message + ) class UserAlreadyAddedError(Code42CLIError): def __init__(self, username, list_name): - msg = u"'{}' is already on the {}.".format(username, list_name) - super(UserAlreadyAddedError, self).__init__(msg) - - -class UnknownRiskTagError(Code42CLIError): - def __init__(self, bad_tags): - tags = u", ".join(bad_tags) - super(UnknownRiskTagError, self).__init__( - u"The following risk tags are unknown: '{}'.".format(tags) - ) + msg = "'{}' is already on the {}.".format(username, list_name) + super().__init__(msg) class InvalidRuleTypeError(Code42CLIError): def __init__(self, rule_id, source): - msg = u"Only alert rules with a source of 'Alerting' can be targeted by this command. " + msg = "Only alert rules with a source of 'Alerting' can be targeted by this command. " msg += "Rule {0} has a source of '{1}'." - super(InvalidRuleTypeError, self).__init__(msg.format(rule_id, source)) + super().__init__(msg.format(rule_id, source)) class UserDoesNotExistError(Code42CLIError): @@ -50,13 +67,13 @@ class UserDoesNotExistError(Code42CLIError): bulk add or remove.""" def __init__(self, username): - super(UserDoesNotExistError, self).__init__(u"User '{}' does not exist.".format(username)) + super().__init__("User '{}' does not exist.".format(username)) class UserNotInLegalHoldError(Code42CLIError): def __init__(self, username, matter_id): - super(UserNotInLegalHoldError, self).__init__( - u"User '{}' is not an active member of legal hold matter '{}'".format( + super().__init__( + "User '{}' is not an active member of legal hold matter '{}'".format( username, matter_id ) ) @@ -64,12 +81,74 @@ def __init__(self, username, matter_id): class LegalHoldNotFoundOrPermissionDeniedError(Code42CLIError): def __init__(self, matter_id): - super(LegalHoldNotFoundOrPermissionDeniedError, self).__init__( - u"Matter with id={} either does not exist or your profile does not have permission to " - u"view it.".format(matter_id) + super().__init__( + "Matter with id={} either does not exist or your profile does not have permission to " + "view it.".format(matter_id) ) -class DateArgumentError(Code42CLIError): - def __init__(self, message=_FORMAT_VALUE_ERROR_MESSAGE): - super(DateArgumentError, self).__init__(message) +class ExceptionHandlingGroup(click.Group): + """Custom click.Group subclass to add custom exception handling.""" + + logger = get_main_cli_logger() + _original_args = None + + def make_context(self, info_name, args, parent=None, **extra): + + # grab the original command line arguments for logging purposes + self._original_args = " ".join(args) + + return super().make_context(info_name, args, parent=parent, **extra) + + def invoke(self, ctx): + try: + return super().invoke(ctx) + + except click.UsageError as err: + self._suggest_cmd(err) + + except LoggedCLIError: + raise + + except Code42CLIError as err: + self.logger.log_error(str(err)) + raise + + except click.ClickException: + raise + + except click.exceptions.Exit: + raise + + except Py42ForbiddenError as err: + self.logger.log_verbose_error(self._original_args, err.response.request) + raise LoggedCLIError( + "You do not have the necessary permissions to perform this task. " + "Try using or creating a different profile." + ) + + except Py42HTTPError as err: + self.logger.log_verbose_error(self._original_args, err.response.request) + raise LoggedCLIError("Problem making request to server.") + + except Exception as err: + self.logger.log_verbose_error() + raise LoggedCLIError("Unknown problem occurred.") + + @staticmethod + def _suggest_cmd(usage_err): + """Handles fuzzy suggestion of commands that are close to the bad command entered.""" + if usage_err.message is not None: + match = re.match("No such command '(.*)'.", usage_err.message) + if match: + bad_arg = match.groups()[0] + available_commands = list(usage_err.ctx.command.commands.keys()) + suggested_commands = difflib.get_close_matches( + bad_arg, available_commands, cutoff=_DIFFLIB_CUT_OFF + ) + if not suggested_commands: + raise usage_err + usage_err.message = "No such command '{}'. Did you mean {}?".format( + bad_arg, " or ".join(suggested_commands) + ) + raise usage_err diff --git a/src/code42cli/file_readers.py b/src/code42cli/file_readers.py index 59e889c7b..81bf0719f 100644 --- a/src/code42cli/file_readers.py +++ b/src/code42cli/file_readers.py @@ -1,56 +1,51 @@ import csv -from code42cli.errors import BadFileError - - -class CliFileReader(object): - _ROWS_COUNT = -1 - - def __init__(self, file_path): - self.file_path = file_path - - def __call__(self, *args, **kwargs): - pass - - def get_rows_count(self): - if self._ROWS_COUNT == -1: - self._ROWS_COUNT = sum(1 for _ in open(self.file_path)) - if self._ROWS_COUNT == 0: - raise BadFileError(u"Given empty file {}.".format(self.file_path)) - return self._ROWS_COUNT - - -class CSVReader(CliFileReader): - """A generator that yields header keys mapped to row values from a csv file.""" - - def __init__(self, file_path): - with open(file_path) as f: - try: - self.has_header = csv.Sniffer().has_header(next(f)) - except StopIteration: - raise BadFileError(u"Given empty file {}.".format(file_path)) - super(CSVReader, self).__init__(file_path) - - def __call__(self, *args, **kwargs): - for row in csv.DictReader(kwargs.get(u"bulk_file")): - yield row - - def get_rows_count(self): - rows_count = super(CSVReader, self).get_rows_count() - return rows_count - 1 if self.has_header else rows_count - - -class FlatFileReader(CliFileReader): - """A generator that yields a single-value per row from a file.""" - - def __call__(self, *args, **kwargs): - for row in kwargs[u"bulk_file"]: - yield row - - -def create_csv_reader(file_path): - return CSVReader(file_path) - - -def create_flat_file_reader(file_path): - return FlatFileReader(file_path) +import click + + +def read_csv_arg(headers): + """Helper for defining arguments that read from a csv file. Automatically converts + the file name provided on command line to a list of csv rows (passed to command + function as `csv_rows` param). + """ + return click.argument( + "csv_rows", + metavar="CSV_FILE", + type=click.File("r"), + callback=lambda ctx, param, arg: read_csv(arg, headers=headers), + ) + + +def read_csv(file, headers=None): + """Helper to read a csv file object into dict rows, automatically removing header row + if it exists, and errors if column count doesn't match header list length. + """ + reader = csv.DictReader(file, fieldnames=headers) + first_row = next(reader) + if None in first_row or None in first_row.values(): + raise click.BadParameter( + "Column count in {} doesn't match expected headers: {}".format(file.name, headers) + ) + # skip first row if it's the header values + if tuple(first_row.keys()) == tuple(first_row.values()): + return list(reader) + else: + return [first_row, *list(reader)] + + +def read_flat_file(file): + """Helper to read rows of a flat file, automatically removing header comment row if + it exists, and strips whitespace from each row automatically.""" + first_row = next(file) + if first_row.startswith("#"): + return [row.strip() for row in file] + else: + return [first_row.strip(), *[row.strip() for row in file]] + + +read_flat_file_arg = click.argument( + "file_rows", + type=click.File("r"), + metavar="FILE", + callback=lambda ctx, param, arg: read_flat_file(arg), +) diff --git a/src/code42cli/invoker.py b/src/code42cli/invoker.py deleted file mode 100644 index 3559ad807..000000000 --- a/src/code42cli/invoker.py +++ /dev/null @@ -1,155 +0,0 @@ -import sys - -import difflib - -from py42.exceptions import Py42HTTPError, Py42ForbiddenError - -from code42cli.compat import str -from code42cli.errors import Code42CLIError -from code42cli.parser import ArgumentParserError, CommandParser -from code42cli.logger import get_main_cli_logger - -_DIFFLIB_CUT_OFF = 0.7 - - -class CommandInvoker(object): - - _COMMAND_KEYWORDS = {} - _COMMAND_ARG_KEYWORDS = {} - - def __init__(self, top_command, cmd_parser=None): - self._top_command = top_command - self._cmd_parser = cmd_parser or CommandParser() - self._commands = {u"": self._top_command} - - def run(self, input_args): - """Locates a command that matches the one specified by - `input_args` and runs it with the supplied parameters. - - Args: - input_args (iter[str]): the full list of arguments - supplied by the user to `code42` cli command. - """ - invocation_str = u"code42 {}".format(u" ".join(input_args)) - try: - path_parts = self._get_path_parts(input_args) - command = self._commands.get(u" ".join(path_parts)) - self._try_run_command(command, path_parts, input_args) - except Code42CLIError as err: - logger = get_main_cli_logger() - logger.print_and_log_error(str(err)) - except Py42ForbiddenError as err: - logger = get_main_cli_logger() - logger.log_verbose_error(invocation_str, err.response.request) - logger.print_and_log_permissions_error() - logger.print_errors_occurred_message() - except Py42HTTPError as err: - logger = get_main_cli_logger() - logger.log_verbose_error(invocation_str, err.response.request) - logger.print_errors_occurred_message() - except Exception: - logger = get_main_cli_logger() - logger.log_verbose_error(invocation_str) - logger.print_errors_occurred_message() - - def _get_path_parts(self, input_args): - """Gets the portion of `input_args` that refers to a - valid command or subcommand, removing parameters. - For example, `input_args` of ["command", "sub", "--arg", "argval"] - would return ["command", "sub"], assuming "command" is a top level command, - "sub" is a subcommand of "command", and the rest of the values are normal parameters. - Returns an empty string if a valid command or subcommand is not found. - """ - path = u"" - node = self._commands[u""] - # step through each segment of input_args until we find - # something that _isn't_ a command or subcommand. - for arg in input_args: - new_path = u"{} {}".format(path, arg).strip() - self._load_subcommands(path, node) - node = self._commands.get(new_path) - if not node: - break - path = new_path - return path.split() - - def _load_subcommands(self, path, node): - """Discovers a command's subcommands and registers them - to the available list of commands for this Invoker.""" - node.load_subcommands() - for command in node.subcommands: - new_key = u"{} {}".format(path, command.name).strip() - self._commands[new_key] = command - self._set_command_keywords(new_key) - - def _try_run_command(self, command, path_parts, input_args): - """Runs a command called using `path_parts` by parsing - `input_args` and calling the command's handler.""" - parser = None - try: - if not path_parts: - parser = self._cmd_parser.prepare_cli_help(command) - else: - parser = self._cmd_parser.prepare_command(command, path_parts) - self._set_argument_keywords(path_parts[0], command.get_arg_configs()) - parsed_args = self._cmd_parser.parse_args(input_args) - parsed_args.func(parsed_args) - except ArgumentParserError as err: - logger = get_main_cli_logger() - logger.print_and_log_error(u"{}".format(err)) - possible_correct_words = self._find_incorrect_word_match(err, path_parts) - if possible_correct_words: - logger.print_info(u"Did you mean one of the following?") - for possible_correct_word in possible_correct_words: - logger.print_info(u" {}".format(possible_correct_word)) - - else: - parser.print_help(sys.stderr) - sys.exit(2) - - @staticmethod - def _get_arg_flags(arguments): - flag_names = [] - for arg in arguments.values(): - arg_flags = [name for name in arg.settings["options_list"] if name.startswith("-")] - flag_names.extend(arg_flags) - return flag_names - - def _set_argument_keywords(self, command_key, arguments): - self._COMMAND_ARG_KEYWORDS[command_key] = set() - self._COMMAND_ARG_KEYWORDS[command_key].update(CommandInvoker._get_arg_flags(arguments)) - - def _set_command_keywords(self, new_key): - """Creates a dictionary, with top level command as key and set of all its subcommands - as values. - """ - command_keys = new_key.split() - if len(command_keys) == 1: - self._COMMAND_KEYWORDS[command_keys[0]] = set() - else: - self._COMMAND_KEYWORDS[command_keys[0]].update(command_keys[1:]) - - def _find_incorrect_word_match(self, error, path_parts): - possible_correct_words = [] - - try: - # Here we assume the error string contains ":", for case where it doesn't we - # assume the error is not due to misspelled word and we return error as is. - error_detail, unmatched_words = str(error).split(u":") - except ValueError: - return possible_correct_words - - if not unmatched_words or error_detail != u"unrecognized arguments": - return possible_correct_words - - # Arg-parser sets the first/leftmost incorrect command keyword in the error message. - unmatched_word = unmatched_words.split()[0] - - if not path_parts: - available_values = self._COMMAND_KEYWORDS.keys() - elif unmatched_word.strip().startswith("-"): - available_values = self._COMMAND_ARG_KEYWORDS[path_parts[0]] - else: - available_values = self._COMMAND_KEYWORDS[path_parts[0]] - - return difflib.get_close_matches(unmatched_word, available_values, cutoff=_DIFFLIB_CUT_OFF) diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index a7ef4494b..2095a841d 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -1,30 +1,39 @@ -import os, logging, sys, traceback +import logging +import os +import sys +import traceback from logging.handlers import RotatingFileHandler from threading import Lock -import copy -from code42cli.compat import str -from code42cli.util import get_user_project_path, is_interactive, color_text_red +from code42cli.util import get_user_project_path # prevent loggers from printing stacks to stderr if a pipe is broken -logging.raiseExceptions = False +logging.raiseExceptions = True logger_deps_lock = Lock() -ERROR_LOG_FILE_NAME = u"code42_errors.log" -_PERMISSIONS_MESSAGE = ( - u"You do not have the necessary permissions to perform this task. " - + u"Try using or creating a different profile." -) +ERROR_LOG_FILE_NAME = "code42_errors.log" -def get_logger_for_stdout(name_suffix=u"main", formatter=None): - logger = logging.getLogger(u"code42_stdout_{}".format(name_suffix)) +def handleError(record): + """Override logger's `handleError` method to exit if an exception is raised while trying to + log, and replace stdout with devnull because if we're here it's usually because stdout has + been closed on us. + """ + t, v, tb = sys.exc_info() + if t == BrokenPipeError: + sys.stdout = open(os.devnull) + sys.exit() + + +def get_logger_for_stdout(name_suffix="main", formatter=None): + logger = logging.getLogger("code42_stdout_{}".format(name_suffix)) if logger_has_handlers(logger): return logger with logger_deps_lock: if not logger_has_handlers(logger): handler = logging.StreamHandler(sys.stdout) + handler.handleError = handleError formatter = formatter or _get_standard_formatter() logger.setLevel(logging.INFO) return add_handler_to_logger(logger, handler, formatter) @@ -32,17 +41,17 @@ def get_logger_for_stdout(name_suffix=u"main", formatter=None): def _get_standard_formatter(): - return logging.Formatter(u"%(message)s") + return logging.Formatter("%(message)s") def _get_error_log_path(): - log_path = get_user_project_path(u"log") + log_path = get_user_project_path("log") return os.path.join(log_path, ERROR_LOG_FILE_NAME) def _create_error_file_handler(): log_path = _get_error_log_path() - return RotatingFileHandler(log_path, maxBytes=250000000, encoding=u"utf-8", delay=True) + return RotatingFileHandler(log_path, maxBytes=250000000, encoding="utf-8", delay=True) def add_handler_to_logger(logger, handler, formatter): @@ -57,7 +66,7 @@ def logger_has_handlers(logger): def _get_error_file_logger(): """Gets the logger where raw exceptions are logged.""" - logger = logging.getLogger(u"code42_error_logger") + logger = logging.getLogger("code42_error_logger") if logger_has_handlers(logger): return logger @@ -69,169 +78,38 @@ def _get_error_file_logger(): return logger -def get_view_exceptions_location_message(): +def get_view_error_details_message(): """Returns the error message that is printed when errors occur.""" path = _get_error_log_path() - return u"View exceptions that occurred at {}.".format(path) - - -def _get_user_error_logger(): - if is_interactive(): - return _get_interactive_user_error_logger() - else: - return _get_error_file_logger() - - -class RedStderrHandler(logging.StreamHandler): - """Logging handler for logging error messages to stderr using red scary text prefixed by the - word `ERROR`. For logging info to stderr, it will not add the scary red text.""" - - def __init__(self): - super(RedStderrHandler, self).__init__(sys.stderr) - - def emit(self, record): - if record.levelno == logging.ERROR: - message = _get_red_error_text(record.msg) - record = copy.copy(record) - record.msg = message - super(RedStderrHandler, self).emit(record) - - -def _get_interactive_user_error_logger(): - """This logger has two handlers, one for stderr and one for the error log file.""" - logger = logging.getLogger(u"code42_stderr_main") - if logger_has_handlers(logger): - return logger - - with logger_deps_lock: - if not logger_has_handlers(logger): - stderr_handler = RedStderrHandler() - stderr_formatter = _get_standard_formatter() - stderr_handler.setFormatter(stderr_formatter) - - file_handler = _create_error_file_handler() - file_formatter = _create_formatter_for_error_file() - file_handler.setFormatter(file_formatter) - - add_handler_to_logger(logger, stderr_handler, stderr_formatter) - add_handler_to_logger(logger, file_handler, file_formatter) - - logger.setLevel(logging.INFO) - return logger - return logger + return "View details in {}".format(path) def _create_formatter_for_error_file(): - return logging.Formatter(u"%(asctime)s %(message)s") - - -def _get_red_error_text(text): - return color_text_red(u"ERROR: {}".format(text)) - - -def get_progress_logger(handler=None): - logger = logging.getLogger(u"code42cli_progress_bar") - if logger_has_handlers(logger): - return logger - - with logger_deps_lock: - if not logger_has_handlers(logger): - handler = handler or InPlaceStreamHandler() - formatter = _get_standard_formatter() - logger.setLevel(logging.INFO) - return add_handler_to_logger(logger, handler, formatter) - return logger - - -class InPlaceStreamHandler(logging.StreamHandler): - def __init__(self): - super(InPlaceStreamHandler, self).__init__(sys.stdout) - - def emit(self, record): - # Borrowed some from python3's logging.StreamHandler to make work on python2. - try: - msg = u"\r{}\r".format(self.format(record)) - stream = self.stream - stream.write(msg) - self.flush() - except RuntimeError as err: - if u"recursion" in str(err): - raise - except Exception: - self.handleError(record) + return logging.Formatter("%(asctime)s %(message)s") class CliLogger(object): - """There are three loggers part of the CliLogger. The following table illustrates where they - log to in both interactive mode and non-interactive mode. - """ - def __init__(self): - """The following properties explain how to log to different locations: - - `self._info_logger` is for when you want to display simple information, like - `profile list`. This does _not_ go to the log file. - - `self._user_error_logger` is for when you want to print in red text to the user. It also - goes to the log file for debugging purposes. - - `self._error_file_logger` logs directly to the error file and is only meant for verbose - debugging information, such as raw exceptions. - """ - self._info_logger = get_logger_for_stdout() - self._user_error_logger = _get_user_error_logger() - self._error_file_logger = _get_error_file_logger() - - def print_info(self, message): - self._info_logger.info(message) - - def print_bold(self, message): - self._info_logger.info(u"\033[1m{}\033[0m".format(message)) - - def print_and_log_error(self, message): - """Logs red error text to stderr and non-color messages to the log file.""" - self._user_error_logger.error(message) - - def print_and_log_info(self, message): - """Prints to stderr and the log file.""" - self._user_error_logger.info(message) + self._logger = _get_error_file_logger() def log_error(self, err): - if err: - message = str(err) # Filter out empty string logs. - if message: - self._error_file_logger.error(message) - - def print_errors_occurred_message(self, additional_info=None): - """Prints a message telling the user how to retrieve error logs.""" - locations_message = get_view_exceptions_location_message() - message = ( - u"{}\n{}".format(additional_info, locations_message) - if additional_info - else locations_message - ) - # Use `info()` because this message is pointless in the error log. - self.print_info(_get_red_error_text(message)) + message = str(err) if err else None + if message: + self._logger.error(message) def log_verbose_error(self, invocation_str=None, http_request=None): """For logging traces, invocation strs, and request parameters during exceptions to the error log file.""" prefix = ( - u"Exception occurred." + "Exception occurred." if not invocation_str - else u"Exception occurred from input: '{}'.".format(invocation_str) + else "Exception occurred from input: '{}'.".format(invocation_str) ) - message = u"{}. See error below.".format(prefix) + message = "{}. See error below.".format(prefix) self.log_error(message) self.log_error(traceback.format_exc()) if http_request: - self.log_error(u"Request parameters: {}".format(http_request.body)) - - def print_and_log_permissions_error(self): - self.print_and_log_error(_PERMISSIONS_MESSAGE) - - def log_permissions_error(self): - self.log_error(_PERMISSIONS_MESSAGE) + self.log_error("Request parameters: {}".format(http_request.body)) def get_main_cli_logger(): diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 2d25df923..bead4bf08 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,133 +1,62 @@ -import platform import signal import sys +import click +from py42.__version__ import __version__ as py42version from py42.settings import set_user_agent_suffix from code42cli import PRODUCT_NAME -from code42cli.cmds.detectionlists import departing_employee as de -from code42cli.cmds.detectionlists import high_risk_employee as hre -from code42cli.cmds.detectionlists.enums import DetectionLists -from code42cli.cmds.securitydata import main as secmain -from code42cli.cmds.alerts import main as alertmain -from code42cli.cmds.alerts.rules import commands as alertrules -from code42cli.cmds.legal_hold import commands as legalhold -from code42cli.cmds.profile import ProfileSubcommandLoader -from code42cli.commands import Command, SubcommandLoader -from code42cli.invoker import CommandInvoker -from code42cli.util import flush_stds_out_err_without_printing_error +from code42cli.__version__ import __version__ as cliversion +from code42cli.cmds.alert_rules import alert_rules +from code42cli.cmds.alerts import alerts +from code42cli.cmds.departing_employee import departing_employee +from code42cli.cmds.high_risk_employee import high_risk_employee +from code42cli.cmds.legal_hold import legal_hold +from code42cli.cmds.profile import profile +from code42cli.cmds.securitydata import security_data +from code42cli.errors import ExceptionHandlingGroup +from code42cli.options import sdk_options + +BANNER = """\b + dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. +dP `" dP Yb 8I Yb 88__ dP 88 "' dP' +Yb Yb dP 8I dY 88"" d888888 dP' + YboodP YbodP 8888Y" 888888 88 .d8888 + +code42cli version {}, by Code42 Software. +powered by py42 version {}.""".format( + cliversion, py42version +) # Handle KeyboardInterrupts by just exiting instead of printing out a stack def exit_on_interrupt(signal, frame): - print() + click.echo() sys.exit(1) signal.signal(signal.SIGINT, exit_on_interrupt) - -# If on Windows, configure console session to handle ANSI escape sequences correctly -# source: https://bugs.python.org/issue29059 -if platform.system().lower() == u"windows": - from ctypes import windll, c_int, byref - - stdout_handle = windll.kernel32.GetStdHandle(c_int(-11)) - mode = c_int(0) - windll.kernel32.GetConsoleMode(c_int(stdout_handle), byref(mode)) - mode = c_int(mode.value | 4) - windll.kernel32.SetConsoleMode(c_int(stdout_handle), mode) - - # Sets part of the user agent string that py42 attaches to requests for the purposes of # identifying CLI users. set_user_agent_suffix(PRODUCT_NAME) - -class MainSubcommandLoader(SubcommandLoader): - PROFILE = u"profile" - SECURITY_DATA = u"security-data" - ALERTS = u"alerts" - ALERT_RULES = u"alert-rules" - DEPARTING_EMPLOYEE = DetectionLists.DEPARTING_EMPLOYEE - HIGH_RISK_EMPLOYEE = DetectionLists.HIGH_RISK_EMPLOYEE - LEGAL_HOLD = u"legal-hold" - - def __init__(self): - super(MainSubcommandLoader, self).__init__(u"") - - def load_commands(self): - detection_lists_description = ( - u"For adding and removing employees from the {} detection list." - ) - return [ - Command( - self.PROFILE, - u"For managing Code42 settings.", - subcommand_loader=self._create_profile_loader(), - ), - Command( - self.SECURITY_DATA, - u"Tools for getting security related data, such as file events.", - subcommand_loader=self._create_security_data_loader(), - ), - Command( - self.ALERTS, - u"Tools for getting alert data.", - subcommand_loader=self._create_alerts_loader(), - ), - Command( - self.ALERT_RULES, - u"Manage alert rules.", - subcommand_loader=self._create_alert_rules_loader(), - ), - Command( - self.DEPARTING_EMPLOYEE, - detection_lists_description.format(u"departing employee"), - subcommand_loader=self._create_departing_employee_loader(), - ), - Command( - self.HIGH_RISK_EMPLOYEE, - detection_lists_description.format(u"high risk employee"), - subcommand_loader=self._create_high_risk_employee_loader(), - ), - Command( - self.LEGAL_HOLD, - u"For adding and removing employees to legal hold matters.", - subcommand_loader=self._create_legal_hold_loader(), - ), - ] - - def _create_profile_loader(self): - return ProfileSubcommandLoader(self.PROFILE) - - def _create_security_data_loader(self): - return secmain.SecurityDataSubcommandLoader(self.SECURITY_DATA) - - def _create_alerts_loader(self): - return alertmain.MainAlertsSubcommandLoader(self.ALERTS) - - def _create_alert_rules_loader(self): - return alertrules.AlertRulesSubcommandLoader(self.ALERT_RULES) - - def _create_departing_employee_loader(self): - return de.DepartingEmployeeSubcommandLoader(self.DEPARTING_EMPLOYEE) - - def _create_high_risk_employee_loader(self): - return hre.HighRiskEmployeeSubcommandLoader(self.HIGH_RISK_EMPLOYEE) - - def _create_legal_hold_loader(self): - return legalhold.LegalHoldSubcommandLoader(self.LEGAL_HOLD) +CONTEXT_SETTINGS = { + "help_option_names": ["-h", "--help"], + "max_content_width": 200, +} -def main(): - top = Command(u"", u"", subcommand_loader=MainSubcommandLoader()) - invoker = CommandInvoker(top) - try: - invoker.run(sys.argv[1:]) - finally: - flush_stds_out_err_without_printing_error() +@click.group(cls=ExceptionHandlingGroup, context_settings=CONTEXT_SETTINGS, help=BANNER) +@sdk_options +def cli(state): + pass -if __name__ == u"__main__": - main() +cli.add_command(alerts) +cli.add_command(alert_rules) +cli.add_command(security_data) +cli.add_command(departing_employee) +cli.add_command(high_risk_employee) +cli.add_command(legal_hold) +cli.add_command(profile) diff --git a/src/code42cli/options.py b/src/code42cli/options.py new file mode 100644 index 000000000..c5a8f678a --- /dev/null +++ b/src/code42cli/options.py @@ -0,0 +1,118 @@ +from collections import OrderedDict + +import click + +from code42cli.errors import Code42CLIError +from code42cli.profile import get_profile +from code42cli.sdk_client import create_sdk + + +class CLIState(object): + def __init__(self): + try: + self._profile = get_profile() + except Code42CLIError: + self._profile = None + self.debug = False + self._sdk = None + self.search_filters = [] + self.cursor_class = None + + @property + def profile(self): + if self._profile is None: + self._profile = get_profile() + return self._profile + + @profile.setter + def profile(self, value): + self._profile = value + + @property + def sdk(self): + if self._sdk is None: + self._sdk = create_sdk(self.profile, self.debug) + return self._sdk + + +def set_profile(ctx, param, value): + """Sets the profile on the global state object when --profile is passed to commands + decorated with @global_options.""" + if value: + ctx.ensure_object(CLIState).profile = get_profile(value) + + +def set_debug(ctx, param, value): + """Sets debug to True on global state object when --debug/-d is passed to commands decorated + with @global_options. + """ + if value: + ctx.ensure_object(CLIState).debug = value + + +profile_option = click.option( + "--profile", + expose_value=False, + callback=set_profile, + help="The name of the Code42 CLI profile to use when executing this command.", +) +debug_option = click.option( + "-d", + "--debug", + is_flag=True, + expose_value=False, + callback=set_debug, + help="Turn on debug logging.", +) +pass_state = click.make_pass_decorator(CLIState, ensure=True) + + +def sdk_options(f): + f = profile_option(f) + f = debug_option(f) + f = pass_state(f) + return f + + +def incompatible_with(incompatible_opts): + + if isinstance(incompatible_opts, str): + incompatible_opts = [incompatible_opts] + + class IncompatibleOption(click.Option): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + # if None it means we're in autocomplete mode and don't want to validate + if ctx.obj is not None: + found_incompatible = ", ".join( + [ + "--{}".format(opt.replace("_", "-")) + for opt in opts + if opt in incompatible_opts + ] + ) + if self.name in opts and found_incompatible: + name = self.name.replace("_", "-") + raise click.BadOptionUsage( + option_name=self.name, + message="--{} can't be used with: {}".format(name, found_incompatible), + ) + return super().handle_parse_result(ctx, opts, args) + + return IncompatibleOption + + +class OrderedGroup(click.Group): + """A click.Group subclass that uses OrderedDict to store commands so the help text lists them + in the order they were defined/added to the group. + """ + + def __init__(self, name=None, commands=None, **attrs): + super().__init__(name, commands, **attrs) + # the registered subcommands by their exported names. + self.commands = commands or OrderedDict() + + def list_commands(self, ctx): + return self.commands diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py index 1cafc5dc1..e69de29bb 100644 --- a/src/code42cli/parser.py +++ b/src/code42cli/parser.py @@ -1,137 +0,0 @@ -import argparse -from argparse import RawDescriptionHelpFormatter, SUPPRESS - -from py42.__version__ import __version__ as py42version - -from code42cli.__version__ import __version__ as cliversion -from code42cli.logger import get_main_cli_logger - - - -BANNER = u""" - dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. -dP `" dP Yb 8I Yb 88__ dP 88 "' dP' -Yb Yb dP 8I dY 88"" d888888 dP' - YboodP YbodP 8888Y" 888888 88 .d8888 - -code42cli version {}, by Code42 Software. -powered by py42 version {}.""".format( - cliversion, py42version -) - - -class ArgumentParserError(Exception): - pass - - -class CommandParser(argparse.ArgumentParser): - def __init__(self, **kwargs): - # noinspection PyTypeChecker - super(CommandParser, self).__init__( - formatter_class=RawDescriptionHelpFormatter, add_help=False, **kwargs - ) - self.add_argument( - "-h", - "--help", - action="help", - default=argparse.SUPPRESS, - help="Show this help message and exit.", - ) - - def prepare_command(self, command, path_parts): - parser = self._get_parser(command, path_parts) - self._load_argparse_config(command, parser) - parser.set_defaults(func=lambda args: command(args, help_func=parser.print_help)) - return parser - - def prepare_cli_help(self, top_command): - top_command.load_subcommands() - self.description = _get_group_help(top_command) - self.usage = SUPPRESS - self.set_defaults(func=lambda _: self.print_help()) - return self - - def error(self, message): - # overrides the behavior of when an error occurs when - # arguments can't be successfully parsed. CommandInvoker catches this. - raise ArgumentParserError(message) - - def _load_argparse_config(self, command, command_parser): - arg_configs = command.get_arg_configs() - required_group = command_parser.add_argument_group(u"required arguments") - for arg in arg_configs: - _add_argument(command_parser, arg_configs[arg].settings, required_group) - - def _get_parser(self, command, path_parts): - usage = command.usage or SUPPRESS - command.load_subcommands() - description = _get_group_help(command) if command.subcommands else command.description - subparser = self._get_subparser(path_parts) - return subparser.add_parser(command.name, description=description, usage=usage) - - def _get_subparser(self, path_parts): - global_subparser = self.add_subparsers() - global_subparser.required = True - subparsers = {(): global_subparser} - parent_subparser = global_subparser - - # build out the entire path of subparsers up to the command - for part in range(0, len(path_parts)): - parent_path_parts = tuple(path_parts[:part]) - parent_subparser = subparsers.get(parent_path_parts) - if not parent_subparser: - parent_subparser = _get_parent_subparser(path_parts, part, subparsers) - subparsers[parent_path_parts] = parent_subparser - return parent_subparser - - -def _get_parent_subparser(path_parts, part, subparsers): - grandparent_path_parts = tuple(path_parts[: part - 1]) - grandparent_subparser = subparsers[grandparent_path_parts] - - new_path = path_parts[part - 1] - new_parser = grandparent_subparser.add_parser(new_path) - parent_subparser = new_parser.add_subparsers() - parent_subparser.required = True - - return parent_subparser - - -def _add_argument(parser, arg_settings, required_group): - # register the settings of an ArgConfig object to an argparse parser - options_list = arg_settings.pop(u"options_list") - arg_settings = {key: arg_settings[key] for key in arg_settings if arg_settings[key] is not None} - if arg_settings.get(u"required"): - parser = required_group - parser.add_argument(*options_list, **arg_settings) - - -def _get_group_help(command): - descriptions = _build_group_command_descriptions(command) - output = [] - name = command.name - if not name: - name = u"code42" - output.append(BANNER) - - output.extend([u" \nAvailable commands in <{}>:".format(name), descriptions]) - return u"\n".join(output) - - -def _build_group_command_descriptions(command): - subs = command.subcommands - name_width = len(max([cmd.name for cmd in subs], key=len)) - lines = [u" {} - {}".format(cmd.name.ljust(name_width), cmd.description) for cmd in subs] - return u"\n".join(lines) - - -def exit_if_mutually_exclusive_args_used_together( - args, invalid_args, incompatible_with=u"--advanced-query" -): - for arg in invalid_args: - if args.__dict__[arg]: - logger = get_main_cli_logger() - logger.print_and_log_error( - u"You cannot use {0} with additional search args.".format(incompatible_with) - ) - exit(1) diff --git a/src/code42cli/password.py b/src/code42cli/password.py index c44545af2..ed32123e3 100644 --- a/src/code42cli/password.py +++ b/src/code42cli/password.py @@ -34,9 +34,9 @@ def delete_password(profile): def _get_keyring_service_name(profile_name): - return u"{}::{}".format(PRODUCT_NAME, profile_name) + return "{}::{}".format(PRODUCT_NAME, profile_name) def _prompt_for_alternative_store(): - prompt = u"keyring is unavailable. Would you like to store in secure flat file? (y/n): " + prompt = "keyring is unavailable. Would you like to store in secure flat file? (y/n): " return does_user_agree(prompt) diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index 5d9556444..69040485b 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -1,8 +1,9 @@ -from code42cli.compat import str +from click import style + import code42cli.password as password -from code42cli.cmds.search_shared.cursor_store import get_all_cursor_stores_for_profile +from code42cli.cmds.search.cursor_store import get_all_cursor_stores_for_profile from code42cli.config import ConfigAccessor, config_accessor, NoConfigProfileError -from code42cli.logger import get_main_cli_logger +from code42cli.errors import Code42CLIError class Code42Profile(object): @@ -28,7 +29,7 @@ def ignore_ssl_errors(self): @property def has_stored_password(self): stored_password = password.get_stored_password(self) - return stored_password is not None and stored_password != u"" + return stored_password is not None and stored_password != "" def get_password(self): pwd = password.get_stored_password(self) @@ -37,7 +38,7 @@ def get_password(self): return pwd def __str__(self): - return u"{0}: Username={1}, Authority URL={2}".format( + return "{0}: Username={1}, Authority URL={2}".format( self.name, self.username, self.authority_url ) @@ -54,10 +55,7 @@ def get_profile(profile_name=None): try: return _get_profile(profile_name) except NoConfigProfileError as ex: - logger = get_main_cli_logger() - logger.print_and_log_error(str(ex)) - _print_create_profile_help() - exit(1) + raise Code42CLIError(str(ex), help=CREATE_PROFILE_HELP) def default_profile_exists(): @@ -78,10 +76,11 @@ def validate_default_profile(): if not default_profile_exists(): existing_profiles = get_all_profiles() if not existing_profiles: - print_and_log_no_existing_profile() + raise Code42CLIError("No existing profile.", help=CREATE_PROFILE_HELP) else: - _print_set_default_profile_help(existing_profiles) - exit(1) + raise Code42CLIError( + "No default profile set.", help=_get_set_default_profile_help(existing_profiles) + ) def profile_exists(profile_name=None): @@ -99,9 +98,7 @@ def switch_default_profile(profile_name): def create_profile(name, server, username, ignore_ssl_errors): if profile_exists(name): - logger = get_main_cli_logger() - logger.print_and_log_error(u"A profile named '{}' already exists.".format(name)) - exit(1) + raise Code42CLIError("A profile named '{}' already exists.".format(name)) config_accessor.create_profile(name, server, username, ignore_ssl_errors) @@ -114,7 +111,6 @@ def delete_profile(profile_name): for store in cursor_stores: store.clean() config_accessor.delete_profile(profile_name) - get_main_cli_logger().print_info(u"Profile '{}' has been deleted.".format(profile_name)) def update_profile(name, server, username, ignore_ssl_errors): @@ -136,27 +132,24 @@ def set_password(new_password, profile_name=None): password.set_password(profile, new_password) -def print_and_log_no_existing_profile(): - logger = get_main_cli_logger() - logger.print_and_log_error(u"No existing profile.") - _print_create_profile_help() - +CREATE_PROFILE_HELP = "\nTo add a profile, use:\n{}".format( + style( + "\tcode42 profile create --name --server --username \n", + bold=True, + ) +) -def _print_create_profile_help(): - logger = get_main_cli_logger() - logger.print_info(u"\nTo add a profile, use: ") - logger.print_bold(u"\tcode42 profile create \n") +def _get_set_default_profile_help(existing_profiles): + existing_profiles = [str(profile) for profile in existing_profiles] + help_msg = """ +Use the --profile flag to specify which profile to use. -def _print_set_default_profile_help(existing_profiles): - logger = get_main_cli_logger() - logger.print_info( - u"\nNo default profile set.\n" - u"\nUse the --profile flag to specify which profile to use.\n" - u"\nTo set the default profile (used whenever --profile argument is not provided), use:" +To set the default profile (used whenever --profile argument is not provided), use: + {} + +Existing profiles: +\t{}""".format( + style("code42 profile use ", bold=True), "\n\t".join(existing_profiles) ) - logger.print_bold(u"\tcode42 profile use ") - logger.print_info(u"\nExisting profiles:") - for profile in existing_profiles: - logger.print_info("\t{}".format(profile)) - logger.print_info(u"") + return help_msg diff --git a/src/code42cli/progress_bar.py b/src/code42cli/progress_bar.py deleted file mode 100644 index 00eec5bf5..000000000 --- a/src/code42cli/progress_bar.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- - -from code42cli.logger import get_progress_logger - - -class ProgressBar(object): - _FILL = u"█" - _LENGTH = 100 - - def __init__(self, total_items, logger=None): - self._total_items = total_items - self._logger = logger or get_progress_logger() - - def update(self, iteration, message): - bar = self._create_bar(iteration) - progress = u"{} {}".format(bar, message.strip()) - self._logger.info(progress) - - def _create_bar(self, iteration): - fill_length = self._calculate_fill_length(iteration) - return self._FILL * fill_length + u"-" * (self._LENGTH - fill_length) - - def _calculate_fill_length(self, idx): - filled_length = int(self._LENGTH * idx // self._total_items) - return filled_length - - def clear_bar_and_print_final(self, final_message): - clear = (self._LENGTH + len(final_message)) * u" " - self._logger.info(u"{}{}\n".format(final_message, clear)) diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index 1e659c049..441e3fa86 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -1,29 +1,33 @@ import py42.sdk -import py42.settings.debug as debug import py42.settings +import py42.settings.debug as debug +from py42.exceptions import Py42UnauthorizedError +from requests.exceptions import ConnectionError +from code42cli.errors import Code42CLIError, LoggedCLIError from code42cli.logger import get_main_cli_logger py42.settings.items_per_page = 500 +logger = get_main_cli_logger() + + def create_sdk(profile, is_debug_mode): if is_debug_mode: py42.settings.debug.level = debug.DEBUG - try: - password = profile.get_password() - return py42.sdk.from_local_account(profile.authority_url, profile.username, password) - except Exception: - logger = get_main_cli_logger() - logger.print_and_log_error( - u"Invalid credentials or host address. " - u"Verify your profile is set up correctly and that you are supplying the correct password." - ) - exit(1) + password = profile.get_password() + return validate_connection(profile.authority_url, profile.username, password) def validate_connection(authority_url, username, password): try: - py42.sdk.from_local_account(authority_url, username, password) - return True - except: - return False + return py42.sdk.from_local_account(authority_url, username, password) + except ConnectionError as err: + logger.log_error(str(err)) + raise LoggedCLIError("Problem connecting to {}".format(authority_url)) + except Py42UnauthorizedError as err: + logger.log_error(str(err)) + raise Code42CLIError("Invalid credentials for user {}".format(username)) + except Exception as err: + logger.log_error(str(err)) + raise LoggedCLIError("Unknown problem validating connection.") diff --git a/src/code42cli/tree_nodes.py b/src/code42cli/tree_nodes.py deleted file mode 100644 index fc30e3f90..000000000 --- a/src/code42cli/tree_nodes.py +++ /dev/null @@ -1,104 +0,0 @@ -class CLINode(object): - """Base class for identifying nodes in the command/argument hierarchy.""" - - @property - def names(self): - """Override""" - return [] - - -class ChoicesNode(CLINode): - """A node who `names` refer to choices the user can select for an argument.""" - - def __init__(self, options): - self._choices = options - - def __iter__(self): - return iter(self._choices) - - def __getitem__(self, item): - return self._choices[item] - - def get(self, item): - return self._choices.get(item) - - @property - def names(self): - return self._choices - - -class ArgNode(CLINode): - """A node whose `names` are a list of flagged arguments the user can select from.""" - - def __init__(self, args): - self.args = args - - @property - def names(self): - try: - arg_names = [ - n - for names in [self.args[key].settings[u"options_list"] for key in self.args] - for n in names - if n.startswith("--") - ] - return arg_names - except: - return self.args - - def __getitem__(self, item): - """Access sub loaders to navigate the argument/options tree, connected to a leaf command.""" - if item in self.args: - return ArgNode(self.args) - - for key in self.args: - arg = self.args[key] - if item not in arg.settings[u"options_list"]: - continue - choices = arg.settings[u"choices"] - if choices: - return ChoicesNode(choices) - return ArgNode(self.args) - - def __iter__(self): - return iter(self.names) - - -class SubcommandNode(CLINode): - """Gets command information ahead of command-execution.""" - - def __init__(self, root_command_name, commands): - self.root = root_command_name - self.commands = commands - - def __getitem__(self, item): - try: - return self._subtrees[item] - except KeyError: - return self._get_args(item) - - def _get_args(self, item): - cmd = self._get_command_by_name(item) - if cmd: - args = cmd.get_arg_configs() - return ArgNode(args) - - def _get_command_by_name(self, name): - for cmd in self.commands: - if cmd.name == name: - return cmd - - @property - def names(self): - """The names of all the subcommands in this subcommand loader's root command.""" - return [cmd.name for cmd in self.commands] - - @property - def _subtrees(self): - """Maps subcommand names to their respective subcommand nodes.""" - results = {} - for cmd in self.commands: - if cmd.subcommand_loader: - commands = cmd.subcommand_loader.load_commands() - results[cmd.name] = SubcommandNode(cmd.name, commands) - return results diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 55551d0f9..d34bfdcac 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,28 +1,21 @@ -from __future__ import print_function -import sys -import shutil import os -import glob -from os import path +import shutil +import sys from collections import OrderedDict from functools import wraps +from os import path from signal import signal, getsignal, SIGINT -from code42cli.compat import open, str -from code42cli.errors import UserDoesNotExistError +from click import echo, style _PADDING_SIZE = 3 -def get_input(prompt): - return input(prompt) - - def does_user_agree(prompt): """Prompts the user and checks if they said yes.""" - ans = get_input(prompt) + ans = input(prompt) ans = ans.strip().lower() - return ans == u"y" + return ans == "y" def get_user_project_path(*subdirs): @@ -37,36 +30,14 @@ def get_user_project_path(*subdirs): return result_path -def open_file(file_path, mode, action): - """Wrapper for opening files, useful for testing purposes.""" - with open(file_path, mode, encoding=u"utf-8") as f: - return action(f) - - def is_interactive(): return sys.stdin.isatty() -def flush_stds_out_err_without_printing_error(): - """Workaround for bug in python3 that causes exception to be printed on broken pipe: - https://bugs.python.org/issue11380 - """ - try: - sys.stdout.flush() - except BrokenPipeError: - try: - sys.stdout.close() - except BrokenPipeError: - try: - sys.stderr.flush() - except BrokenPipeError: - sys.stderr.close() - - def get_url_parts(url_str): - parts = url_str.split(u":") + parts = url_str.split(":") port = None - if len(parts) > 1 and parts[1] != u"": + if len(parts) > 1 and parts[1] != "": port = int(parts[1]) return parts[0], port @@ -104,8 +75,8 @@ def format_to_table(rows, column_size): """Prints result in left justified format in a tabular form.""" for row in rows: for key in row.keys(): - print(str(row[key]).ljust(column_size[key] + _PADDING_SIZE), end=u" ") - print(u"") + echo(str(row[key]).ljust(column_size[key] + _PADDING_SIZE), nl=False) + echo("") def format_string_list_to_columns(string_list, max_width=None): @@ -116,16 +87,12 @@ def format_string_list_to_columns(string_list, max_width=None): max_width, _ = shutil.get_terminal_size() column_width = len(max(string_list, key=len)) + _PADDING_SIZE num_columns = int(max_width / column_width) - format_string = u"{{:<{0}}}".format(column_width) * num_columns + format_string = "{{:<{0}}}".format(column_width) * num_columns batches = [string_list[i : i + num_columns] for i in range(0, len(string_list), num_columns)] - padding = [u"" for _ in range(num_columns)] + padding = ["" for _ in range(num_columns)] for batch in batches: - print(format_string.format(*batch + padding)) - print() - - -def color_text_red(text): - return u"\033[91m{}\033[0m".format(text) + echo(format_string.format(*batch + padding)) + echo() class warn_interrupt(object): @@ -144,7 +111,7 @@ def __init__(self, warning="Cancelling operation cleanly, one moment... "): self.warning = warning self.old_int_handler = None self.interrupted = False - self.exit_instructions = "Hit CTRL-C again to force quit." + self.exit_instructions = style("Hit CTRL-C again to force quit.", fg="red") def __enter__(self): self.old_int_handler = getsignal(SIGINT) @@ -161,7 +128,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def _handle_interrupts(self, sig, frame): if not self.interrupted: self.interrupted = True - print("\n{}\n{}".format(self.warning, self.exit_instructions), file=sys.stderr) + echo("\n{}\n{}".format(self.warning, self.exit_instructions), err=True) else: exit() @@ -172,38 +139,3 @@ def inner(*args, **kwargs): return func(*args, **kwargs) return inner - - -def get_files_in_path(input_path): - try: - if not input_path: - return os.listdir(os.getcwd()) - - if "~" in input_path: - replace = os.path.expanduser("~") - input_path = input_path.replace("~", replace) - - if os.path.isdir(input_path) and input_path[-1] != os.sep: - input_path += os.sep - - files = glob.glob(input_path + "*") - return files - except Exception: - return [] - - -def get_user_id(sdk, username): - """Returns the user's UID (referred to by `user_id` in detection lists). Raises - `UserDoesNotExistError` if the user doesn't exist in the Code42 server. - - Args: - sdk (py42.sdk.SDKClient): The py42 sdk. - username (str or unicode): The username of the user to get an ID for. - - Returns: - str: The user ID for the user with the given username. - """ - users = sdk.users.get_by_username(username)[u"users"] - if not users: - raise UserDoesNotExistError(username) - return users[0][u"userUid"] diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index f6ec9cf32..ec94bdcb8 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -1,10 +1,10 @@ +import queue from threading import Thread, Lock from time import sleep from py42.exceptions import Py42HTTPError, Py42ForbiddenError from code42cli.errors import Code42CLIError -from code42cli.compat import queue from code42cli.logger import get_main_cli_logger @@ -35,7 +35,7 @@ def total_successes(self): return val if val >= 0 else 0 def __str__(self): - return u"{0} succeeded, {1} failed out of {2}".format( + return "{0} succeeded, {1} failed out of {2}".format( self.total_successes, self._total_errors, self.total ) @@ -51,13 +51,15 @@ def increment_total_errors(self): class Worker(object): - def __init__(self, thread_count, expected_total): + def __init__(self, thread_count, expected_total, bar=None): self._queue = queue.Queue() self._thread_count = thread_count self._stats = WorkerStats(expected_total) self._tasks = 0 self.__started = False self.__start_lock = Lock() + self._logger = get_main_cli_logger() + self._bar = bar def do_async(self, func, *args, **kwargs): """Execute the given func asynchronously given *args and **kwargs. @@ -72,7 +74,7 @@ def do_async(self, func, *args, **kwargs): if not self.__started: self.__start() self.__started = True - self._queue.put({u"func": func, u"args": args, u"kwargs": kwargs}) + self._queue.put({"func": func, "args": args, "kwargs": kwargs}) self._tasks += 1 @property @@ -84,36 +86,37 @@ def stats(self): def wait(self): """Wait for the tasks in the queue to complete. This should usually be called before program termination.""" - while not self._stats.total_processed >= self._tasks: + while self._stats.total_processed < self._tasks: sleep(0.5) def _process_queue(self): while True: try: task = self._queue.get() - func = task[u"func"] - args = task[u"args"] - kwargs = task[u"kwargs"] + func = task["func"] + args = task["args"] + kwargs = task["kwargs"] func(*args, **kwargs) except Code42CLIError as err: self._increment_total_errors() - logger = get_main_cli_logger() - logger.log_error(err) + self._logger.log_error(err) except Py42ForbiddenError as err: self._increment_total_errors() - logger = get_main_cli_logger() - logger.log_verbose_error(http_request=err.response.request) - logger.log_permissions_error() + self._logger.log_verbose_error(http_request=err.response.request) + self._logger.log_error( + "You do not have the necessary permissions to perform this task. " + "Try using or creating a different profile." + ) except Py42HTTPError as err: self._increment_total_errors() - logger = get_main_cli_logger() - logger.log_verbose_error(http_request=err.response.request) + self._logger.log_verbose_error(http_request=err.response.request) except Exception: self._increment_total_errors() - logger = get_main_cli_logger() - logger.log_verbose_error() + self._logger.log_verbose_error() finally: self._stats.increment_total_processed() + if self._bar: + self._bar.update(1) self._queue.task_done() def __start(self): diff --git a/tests/cmds/alerts/__init__.py b/tests/cmds/alerts/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/alerts/rules/__init__.py b/tests/cmds/alerts/rules/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/alerts/rules/conftest.py b/tests/cmds/alerts/rules/conftest.py deleted file mode 100644 index 074b04422..000000000 --- a/tests/cmds/alerts/rules/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - - -@pytest.fixture -def alert_rules_sdk(sdk): - sdk.alerts.rules.add_user.return_value = {} - sdk.alerts.rules.remove_user.return_value = {} - sdk.alerts.rules.remove_all_users.return_value = {} - sdk.alerts.rules.get_all.return_value = {} - sdk.alerts.rules.exfiltration.get.return_value = {} - sdk.alerts.rules.cloudshare.get.return_value = {} - sdk.alerts.rules.filetypemismatch.get.return_value = {} - return sdk diff --git a/tests/cmds/alerts/rules/test_user_rule.py b/tests/cmds/alerts/rules/test_user_rule.py deleted file mode 100644 index f0757eb8e..000000000 --- a/tests/cmds/alerts/rules/test_user_rule.py +++ /dev/null @@ -1,167 +0,0 @@ -import pytest -from requests import Response, HTTPError - -from py42.exceptions import Py42InternalServerError -from code42cli.errors import InvalidRuleTypeError -from code42cli.cmds.alerts.rules.user_rule import add_user, remove_user, get_rules, show_rule - -import logging - -TEST_RULE_ID = u"rule-id" -TEST_USER_ID = u"test-user-id" -TEST_USERNAME = "test@code42.com" - -TEST_EMPTY_RULE_RESPONSE = {u"ruleMetadata": []} - -TEST_SYSTEM_RULE_REPONSE = { - u"ruleMetadata": [ - { - u"observerRuleId": TEST_RULE_ID, - u"type": u"FED_FILE_TYPE_MISMATCH", - u"isSystem": True, - u"ruleSource": "NOTVALID", - } - ] -} - -TEST_USER_RULE_REPONSE = { - u"ruleMetadata": [ - { - u"observerRuleId": TEST_RULE_ID, - u"type": u"FED_FILE_TYPE_MISMATCH", - u"isSystem": False, - u"ruleSource": "Testing", - } - ] -} - -TEST_GET_ALL_RESPONSE_EXFILTRATION = { - u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_ENDPOINT_EXFILTRATION"}] -} -TEST_GET_ALL_RESPONSE_CLOUD_SHARE = { - u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_CLOUD_SHARE_PERMISSIONS"}] -} -TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH = { - u"ruleMetadata": [{u"observerRuleId": TEST_RULE_ID, u"type": u"FED_FILE_TYPE_MISMATCH"}] -} - - -@pytest.fixture -def mock_server_error(mocker): - base_err = HTTPError() - mock_response = mocker.MagicMock(spec=Response) - base_err.response = mock_response - return Py42InternalServerError(base_err) - - -def test_add_user_adds_user_list_to_alert_rules(alert_rules_sdk, profile): - alert_rules_sdk.users.get_by_username.return_value = {u"users": [{u"userUid": TEST_USER_ID}]} - add_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) - alert_rules_sdk.alerts.rules.add_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) - - -def test_add_user_when_non_existent_alert_prints_no_rules_message(alert_rules_sdk, profile, caplog): - with caplog.at_level(logging.INFO): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE - add_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) - msg = u"No alert rules with RuleId {} found".format(TEST_RULE_ID) - assert msg in caplog.text - - -def test_add_user_when_returns_500_and_system_rule_raises_InvalidRuleTypeError( - alert_rules_sdk, profile, mock_server_error, caplog -): - with caplog.at_level(logging.INFO): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_SYSTEM_RULE_REPONSE - alert_rules_sdk.alerts.rules.add_user.side_effect = mock_server_error - with pytest.raises(InvalidRuleTypeError): - add_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) - - -def test_add_user_when_returns_500_and_not_system_rule_raises_Py42InternalServerError( - alert_rules_sdk, profile, mock_server_error, caplog -): - with caplog.at_level(logging.INFO): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_USER_RULE_REPONSE - alert_rules_sdk.alerts.rules.add_user.side_effect = mock_server_error - with pytest.raises(Py42InternalServerError): - add_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) - - -def test_remove_user_removes_user_list_from_alert_rules(alert_rules_sdk, profile): - alert_rules_sdk.users.get_by_username.return_value = {u"users": [{u"userUid": TEST_USER_ID}]} - remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) - alert_rules_sdk.alerts.rules.remove_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) - - -def test_remove_user_when_non_existent_alert_prints_no_rules_message( - alert_rules_sdk, profile, caplog -): - with caplog.at_level(logging.INFO): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE - remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) - msg = u"No alert rules with RuleId {} found".format(TEST_RULE_ID) - assert msg in caplog.text - - -def test_remove_user_when_returns_500_and_system_rule_raises_InvalidRuleTypeError( - alert_rules_sdk, profile, mock_server_error, caplog -): - with caplog.at_level(logging.INFO): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_SYSTEM_RULE_REPONSE - alert_rules_sdk.alerts.rules.remove_user.side_effect = mock_server_error - with pytest.raises(InvalidRuleTypeError): - remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) - - -def test_remove_user_when_returns_500_and_not_system_rule_raises_Py42InternalServerError( - alert_rules_sdk, profile, mock_server_error, caplog -): - with caplog.at_level(logging.INFO): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_USER_RULE_REPONSE - alert_rules_sdk.alerts.rules.remove_user.side_effect = mock_server_error - with pytest.raises(Py42InternalServerError): - remove_user(alert_rules_sdk, profile, TEST_RULE_ID, TEST_USERNAME) - - -def test_get_rules_gets_alert_rules(alert_rules_sdk, profile): - get_rules(alert_rules_sdk, profile) - assert alert_rules_sdk.alerts.rules.get_all.call_count == 1 - - -def test_get_rules_when_no_rules_prints_no_rules_message(alert_rules_sdk, profile, caplog): - with caplog.at_level(logging.INFO): - alert_rules_sdk.alerts.rules.get_all.return_value = [TEST_EMPTY_RULE_RESPONSE] - get_rules(alert_rules_sdk, profile) - msg = u"No alert rules found".format(TEST_RULE_ID) - assert msg in caplog.text - - -def test_show_rule_calls_correct_rule_property(alert_rules_sdk, profile): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = ( - TEST_GET_ALL_RESPONSE_EXFILTRATION - ) - show_rule(alert_rules_sdk, profile, TEST_RULE_ID) - alert_rules_sdk.alerts.rules.exfiltration.get.assert_called_once_with(TEST_RULE_ID) - - -def test_show_rule_calls_correct_rule_property_cloud_share(alert_rules_sdk, profile): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_GET_ALL_RESPONSE_CLOUD_SHARE - show_rule(alert_rules_sdk, profile, TEST_RULE_ID) - alert_rules_sdk.alerts.rules.cloudshare.get.assert_called_once_with(TEST_RULE_ID) - - -def test_show_rule_calls_correct_rule_property_file_type_mismatch(alert_rules_sdk, profile): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = ( - TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH - ) - show_rule(alert_rules_sdk, profile, TEST_RULE_ID) - alert_rules_sdk.alerts.rules.filetypemismatch.get.assert_called_once_with(TEST_RULE_ID) - - -def test_show_rule_when_no_matching_rule_prints_no_rule_message(alert_rules_sdk, profile, caplog): - with caplog.at_level(logging.INFO): - alert_rules_sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE - show_rule(alert_rules_sdk, profile, TEST_RULE_ID) - msg = u"No alert rules with RuleId {} found".format(TEST_RULE_ID) - assert msg in caplog.text diff --git a/tests/cmds/alerts/test_extraction.py b/tests/cmds/alerts/test_extraction.py deleted file mode 100644 index c1fda7677..000000000 --- a/tests/cmds/alerts/test_extraction.py +++ /dev/null @@ -1,367 +0,0 @@ -import logging - -import pytest -from py42.sdk.queries.alerts.filters import * - -import code42cli.cmds.alerts.extraction as extraction_module -import code42cli.errors as errors -from code42cli import PRODUCT_NAME -from code42cli.errors import DateArgumentError -from tests.cmds.conftest import get_filter_value_from_json -from ...conftest import get_test_date_str, begin_date_str, ErrorTrackerTestHelper - - -@pytest.fixture -def alert_extractor(mocker): - mock = mocker.MagicMock() - mock.extract_advanced = mocker.patch( - "c42eventextractor.extractors.AlertExtractor.extract_advanced" - ) - mock.extract = mocker.patch("c42eventextractor.extractors.AlertExtractor.extract") - return mock - - -@pytest.fixture -def alert_namespace_with_begin(alert_namespace): - alert_namespace.begin = begin_date_str - return alert_namespace - - -@pytest.fixture -def alert_checkpoint(mocker): - return mocker.patch( - "{}.cmds.search_shared.cursor_store.AlertCursorStore.get".format(PRODUCT_NAME) - ) - - -def filter_term_is_in_call_args(extractor, term): - arg_filters = extractor.extract.call_args[0] - for f in arg_filters: - if term in str(f): - return True - return False - - -def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( - sdk, profile, logger, alert_namespace, alert_extractor -): - alert_namespace.advanced_query = "some complex json" - extraction_module.extract(sdk, profile, logger, alert_namespace) - alert_extractor.extract_advanced.assert_called_once_with("some complex json") - assert alert_extractor.extract.call_count == 0 - - -def test_extract_when_is_not_advanced_query_uses_only_extract_method( - sdk, profile, logger, alert_extractor, alert_namespace_with_begin -): - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert alert_extractor.extract.call_count == 1 - assert alert_extractor.extract_raw.call_count == 0 - - -def test_extract_when_not_given_begin_or_advanced_causes_exit( - sdk, profile, logger, alert_namespace -): - alert_namespace.begin = None - alert_namespace.advanced_query = None - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -def test_extract_when_given_begin_date_uses_expected_query( - sdk, profile, logger, alert_namespace, alert_extractor -): - alert_namespace.begin = get_test_date_str(days_ago=89) - extraction_module.extract(sdk, profile, logger, alert_namespace) - actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T00:00:00.000Z".format(alert_namespace.begin) - assert actual == expected - - -def test_extract_when_given_begin_date_and_time_uses_expected_query( - sdk, profile, logger, alert_namespace, alert_extractor -): - date = get_test_date_str(days_ago=89) - time = "15:33:02" - alert_namespace.begin = get_test_date_str(days_ago=89) + " " + time - extraction_module.extract(sdk, profile, logger, alert_namespace) - actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T{1}.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_given_begin_date_and_time_without_seconds_uses_expected_query( - sdk, profile, logger, alert_namespace, alert_extractor -): - date = get_test_date_str(days_ago=89) - time = "15:33" - alert_namespace.begin = get_test_date_str(days_ago=89) + " " + time - extraction_module.extract(sdk, profile, logger, alert_namespace) - actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T{1}:00.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_given_end_date_uses_expected_query( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.end = get_test_date_str(days_ago=10) - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T23:59:59.999Z".format(alert_namespace_with_begin.end) - assert actual == expected - - -def test_extract_when_given_end_date_and_time_uses_expected_query( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - date = get_test_date_str(days_ago=10) - time = "12:00:11" - alert_namespace_with_begin.end = date + " " + time - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T{1}.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_given_end_date_and_time_without_seconds_uses_expected_query( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - date = get_test_date_str(days_ago=10) - time = "12:00" - alert_namespace_with_begin.end = date + " " + time - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T{1}:00.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( - sdk, profile, logger, alert_namespace, alert_extractor -): - end_date = get_test_date_str(days_ago=55) - end_time = "13:44:44" - alert_namespace.begin = get_test_date_str(days_ago=89) - alert_namespace.end = end_date + " " + end_time - extraction_module.extract(sdk, profile, logger, alert_namespace) - - actual_begin_timestamp = get_filter_value_from_json( - alert_extractor.extract.call_args[0][0], filter_index=0 - ) - actual_end_timestamp = get_filter_value_from_json( - alert_extractor.extract.call_args[0][0], filter_index=1 - ) - expected_begin_timestamp = "{0}T00:00:00.000Z".format(alert_namespace.begin) - expected_end_timestamp = "{0}T{1}.000Z".format(end_date, end_time) - - assert actual_begin_timestamp == expected_begin_timestamp - assert actual_end_timestamp == expected_end_timestamp - - -def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( - sdk, profile, logger, alert_namespace -): - alert_namespace.use_checkpoint = None - date = get_test_date_str(days_ago=91) + " 12:51:00" - alert_namespace.begin = date - with pytest.raises(DateArgumentError): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -def test_extract_when_end_date_is_before_begin_date_causes_exit( - sdk, profile, logger, alert_namespace -): - alert_namespace.begin = get_test_date_str(days_ago=5) - alert_namespace.end = get_test_date_str(days_ago=6) - with pytest.raises(DateArgumentError): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -def test_when_given_begin_date_past_90_days_and_uses_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - sdk, profile, logger, alert_namespace, alert_extractor, alert_checkpoint -): - alert_namespace.begin = "2019-01-01" - alert_namespace.use_checkpoint = "foo" - alert_checkpoint.return_value = 22624624 - extraction_module.extract(sdk, profile, logger, alert_namespace) - assert not filter_term_is_in_call_args(alert_extractor, DateObserved._term) - - -def test_when_given_begin_date_and_not_use_checkpoint_mode_and_cursor_exists_uses_begin_date( - sdk, profile, logger, alert_namespace, alert_extractor, alert_checkpoint -): - alert_namespace.begin = get_test_date_str(days_ago=1) - alert_namespace.use_checkpoint = None - alert_checkpoint.return_value = 22624624 - extraction_module.extract(sdk, profile, logger, alert_namespace) - - actual_ts = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) - expected_ts = "{0}T00:00:00.000Z".format(alert_namespace.begin) - assert actual_ts == expected_ts - assert filter_term_is_in_call_args(alert_extractor, DateObserved._term) - - -def test_when_not_given_begin_date_and_uses_checkpoint_but_no_stored_checkpoint_exists_causes_exit( - sdk, profile, logger, alert_namespace, alert_checkpoint -): - alert_namespace.begin = None - alert_namespace.use_checkpoint = "foo" - alert_checkpoint.return_value = None - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -def test_extract_when_given_actor_is_uses_username_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.actor = ["test.testerson@example.com"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - Actor.is_in(alert_namespace_with_begin.actor) - ) - - -def test_extract_when_given_exclude_actor_uses_actor_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.exclude_actor = ["test.testerson"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - Actor.not_in(alert_namespace_with_begin.exclude_actor) - ) - - -def test_extract_when_given_rule_name_uses_rule_name_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.rule_name = ["departing employee"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - RuleName.is_in(alert_namespace_with_begin.rule_name) - ) - - -def test_extract_when_given_exclude_rule_name_uses_rule_name_not_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.exclude_rule_name = ["departing employee"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - RuleName.not_in(alert_namespace_with_begin.exclude_rule_name) - ) - - -def test_extract_when_given_rule_type_uses_rule_name_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.rule_type = ["departing employee"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - RuleType.is_in(alert_namespace_with_begin.rule_type) - ) - - -def test_extract_when_given_exclude_rule_type_uses_rule_name_not_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.exclude_rule_type = ["departing employee"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - RuleType.not_in(alert_namespace_with_begin.exclude_rule_type) - ) - - -def test_extract_when_given_rule_id_uses_rule_name_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.rule_id = ["departing employee"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - RuleId.is_in(alert_namespace_with_begin.rule_id) - ) - - -def test_extract_when_given_exclude_rule_id_uses_rule_name_not_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.exclude_rule_id = ["departing employee"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - RuleId.not_in(alert_namespace_with_begin.exclude_rule_id) - ) - - -def test_extract_when_given_description_uses_description_filter( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.description = ["catch the bad guys"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - Description.contains(alert_namespace_with_begin.description) - ) - - -def test_extract_when_given_multiple_search_args_uses_expected_filters( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor -): - alert_namespace_with_begin.actor = ["test.testerson@example.com"] - alert_namespace_with_begin.exclude_actor = ["flag.flagerson@code42.com"] - alert_namespace_with_begin.rule_name = ["departing employee"] - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert str(alert_extractor.extract.call_args[0][1]) == str( - Actor.is_in(alert_namespace_with_begin.actor) - ) - assert str(alert_extractor.extract.call_args[0][2]) == str( - Actor.not_in(alert_namespace_with_begin.exclude_actor) - ) - assert str(alert_extractor.extract.call_args[0][3]) == str( - RuleName.is_in(alert_namespace_with_begin.rule_name) - ) - - -def test_extract_when_creating_sdk_throws_causes_exit( - sdk, profile, logger, alert_namespace, mock_42 -): - def side_effect(): - raise Exception() - - mock_42.side_effect = side_effect - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, alert_namespace) - - -def test_extract_when_not_errored_and_does_not_log_error_occurred( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor, caplog -): - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - with caplog.at_level(logging.ERROR): - assert "View exceptions that occurred at" not in caplog.text - - -def test_extract_when_not_errored_and_is_interactive_does_not_print_error( - sdk, profile, logger, alert_namespace_with_begin, alert_extractor, cli_logger, mocker -): - errors.ERRORED = False - mocker.patch("code42cli.cmds.securitydata.extraction.logger", cli_logger) - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert cli_logger.print_and_log_error.call_count == 0 - assert cli_logger.log_error.call_count == 0 - errors.ERRORED = False - - -def test_when_sdk_raises_exception_global_variable_gets_set( - mocker, sdk, profile, logger, alert_namespace_with_begin, mock_42 -): - errors.ERRORED = False - mock_sdk = mocker.MagicMock() - - def sdk_side_effect(self, *args): - raise Exception() - - mock_sdk.security.search_file_events.side_effect = sdk_side_effect - mock_42.return_value = mock_sdk - - mocker.patch("c42eventextractor.extractors.BaseExtractor._verify_filter_groups") - with ErrorTrackerTestHelper(): - extraction_module.extract(sdk, profile, logger, alert_namespace_with_begin) - assert errors.ERRORED diff --git a/tests/cmds/alerts/test_main.py b/tests/cmds/alerts/test_main.py deleted file mode 100644 index b86b58f50..000000000 --- a/tests/cmds/alerts/test_main.py +++ /dev/null @@ -1,103 +0,0 @@ -import pytest - -import code42cli.cmds.alerts.main as main -from code42cli import PRODUCT_NAME - - -@pytest.fixture -def mock_logger_factory(mocker): - return mocker.patch("{}.cmds.alerts.main.logger_factory".format(PRODUCT_NAME)) - - -@pytest.fixture -def mock_extract(mocker): - return mocker.patch("{}.cmds.alerts.main.extract".format(PRODUCT_NAME)) - - -def test_print_out(sdk, profile, alert_namespace, mocker, mock_logger_factory, mock_extract): - logger = mocker.MagicMock() - mock_logger_factory.get_logger_for_stdout.return_value = logger - main.print_out(sdk, profile, alert_namespace) - mock_extract.assert_called_with(sdk, profile, logger, alert_namespace) - - -def test_write_to(sdk, profile, alert_namespace, mocker, mock_logger_factory, mock_extract): - logger = mocker.MagicMock() - mock_logger_factory.get_logger_for_file.return_value = logger - main.write_to(sdk, profile, alert_namespace) - mock_extract.assert_called_with(sdk, profile, logger, alert_namespace) - - -def test_send_to(sdk, profile, alert_namespace, mocker, mock_logger_factory, mock_extract): - logger = mocker.MagicMock() - mock_logger_factory.get_logger_for_server.return_value = logger - main.send_to(sdk, profile, alert_namespace) - mock_extract.assert_called_with(sdk, profile, logger, alert_namespace) - - -def test_extract_when_is_advanced_query_and_has_begin_date_exits(sdk, profile, alert_namespace): - alert_namespace.advanced_query = "some complex json" - alert_namespace.begin = "begin date" - with pytest.raises(SystemExit): - main.send_to(sdk, profile, alert_namespace) - - -def test_extract_when_is_advanced_query_and_has_end_date_exits(sdk, profile, alert_namespace): - alert_namespace.advanced_query = "some complex json" - alert_namespace.end = "end date" - with pytest.raises(SystemExit): - main.print_out(sdk, profile, alert_namespace) - - -@pytest.mark.parametrize( - "arg", - [ - "severity", - "actor", - "actor_contains", - "exclude_actor", - "exclude_actor_contains", - "rule_name", - "exclude_rule_name", - "rule_id", - "exclude_rule_id", - "rule_type", - "exclude_rule_type", - ], -) -def test_extract_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( - sdk, profile, alert_namespace, arg -): - alert_namespace.advanced_query = "some complex json" - setattr(alert_namespace, arg, ["test_value"]) - with pytest.raises(SystemExit): - main.write_to(sdk, profile, alert_namespace) - - -@pytest.mark.parametrize("arg", ["state", "description"]) -def test_extract_when_is_advanced_query_and_other_incompatible_single_arg_argument_passed( - sdk, profile, alert_namespace, arg -): - alert_namespace.advanced_query = "some complex json" - setattr(alert_namespace, arg, "test_value") - with pytest.raises(SystemExit): - main.print_out(sdk, profile, alert_namespace) - - -def test_extract_when_is_advanced_query_and_use_checkpoint_mode_exits( - sdk, profile, alert_namespace -): - alert_namespace.advanced_query = "some complex json" - alert_namespace.use_checkpoint = "foo" - with pytest.raises(SystemExit): - main.print_out(sdk, profile, alert_namespace) - - -def test_extract_when_is_advanced_query_and_does_not_use_checkpoint_does_not_exit( - sdk, profile, alert_namespace, mock_extract, mocker, mock_logger_factory -): - logger = mocker.MagicMock() - mock_logger_factory.get_logger_for_server.return_value = logger - alert_namespace.advanced_query = "some complex json" - alert_namespace.use_checkpoint = None - main.print_out(sdk, profile, alert_namespace) diff --git a/tests/cmds/alerts/test_util.py b/tests/cmds/alerts/test_util.py deleted file mode 100644 index f1da19a07..000000000 --- a/tests/cmds/alerts/test_util.py +++ /dev/null @@ -1,54 +0,0 @@ -import code42cli.cmds.alerts.util as alert_util - - -ALERT_SUMMARY_LIST = [{"id": i} for i in range(20)] - -ALERT_DETAIL_RESULT = [ - {"alerts": [{"id": 1, "createdAt": "2020-01-17"}, {"id": 11, "createdAt": "2020-01-18"}]}, - {"alerts": [{"id": 2, "createdAt": "2020-01-19"}, {"id": 12, "createdAt": "2020-01-20"}]}, - {"alerts": [{"id": 3, "createdAt": "2020-01-01"}, {"id": 13, "createdAt": "2020-01-02"}]}, - {"alerts": [{"id": 4, "createdAt": "2020-01-03"}, {"id": 14, "createdAt": "2020-01-04"}]}, - {"alerts": [{"id": 5, "createdAt": "2020-01-05"}, {"id": 15, "createdAt": "2020-01-06"}]}, - {"alerts": [{"id": 6, "createdAt": "2020-01-07"}, {"id": 16, "createdAt": "2020-01-08"}]}, - {"alerts": [{"id": 7, "createdAt": "2020-01-09"}, {"id": 17, "createdAt": "2020-01-10"}]}, - {"alerts": [{"id": 8, "createdAt": "2020-01-11"}, {"id": 18, "createdAt": "2020-01-12"}]}, - {"alerts": [{"id": 9, "createdAt": "2020-01-13"}, {"id": 19, "createdAt": "2020-01-14"}]}, - {"alerts": [{"id": 10, "createdAt": "2020-01-15"}, {"id": 20, "createdAt": "2020-01-16"}]}, -] - -SORTED_ALERT_DETAILS = [ - {"id": 12, "createdAt": "2020-01-20"}, - {"id": 2, "createdAt": "2020-01-19"}, - {"id": 11, "createdAt": "2020-01-18"}, - {"id": 1, "createdAt": "2020-01-17"}, - {"id": 20, "createdAt": "2020-01-16"}, - {"id": 10, "createdAt": "2020-01-15"}, - {"id": 19, "createdAt": "2020-01-14"}, - {"id": 9, "createdAt": "2020-01-13"}, - {"id": 18, "createdAt": "2020-01-12"}, - {"id": 8, "createdAt": "2020-01-11"}, - {"id": 17, "createdAt": "2020-01-10"}, - {"id": 7, "createdAt": "2020-01-09"}, - {"id": 16, "createdAt": "2020-01-08"}, - {"id": 6, "createdAt": "2020-01-07"}, - {"id": 15, "createdAt": "2020-01-06"}, - {"id": 5, "createdAt": "2020-01-05"}, - {"id": 14, "createdAt": "2020-01-04"}, - {"id": 4, "createdAt": "2020-01-03"}, - {"id": 13, "createdAt": "2020-01-02"}, - {"id": 3, "createdAt": "2020-01-01"}, -] - - -def test_get_alert_details_batches_results_according_to_batch_size(sdk): - alert_util._BATCH_SIZE = 2 - sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT - results = alert_util.get_alert_details(sdk, ALERT_SUMMARY_LIST) - assert sdk.alerts.get_details.call_count == 10 - - -def test_get_alert_details_sorts_results_by_date(sdk): - alert_util._BATCH_SIZE = 2 - sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT - results = alert_util.get_alert_details(sdk, ALERT_SUMMARY_LIST) - assert results == SORTED_ALERT_DETAILS diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 90d37e6c4..e22ddea44 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -1,7 +1,10 @@ import json as json_module +import threading import pytest +from requests import Request, Response, HTTPError +from py42.exceptions import Py42BadRequestError from py42.sdk import SDKClient from code42cli import PRODUCT_NAME from code42cli.logger import CliLogger @@ -21,7 +24,6 @@ def mock_42(mocker): @pytest.fixture def logger(mocker): mock = mocker.MagicMock() - mock.print_info = mocker.MagicMock() return mock @@ -31,46 +33,85 @@ def cli_logger(mocker): return mock +@pytest.fixture +def stdout_logger(mocker): + mock = mocker.patch("{}.cmds.search.logger_factory.get_logger_for_stdout".format(PRODUCT_NAME)) + mock.return_value = mocker.MagicMock() + return mock + + +@pytest.fixture +def server_logger(mocker): + mock = mocker.patch("{}.cmds.search.logger_factory.get_logger_for_server".format(PRODUCT_NAME)) + mock.return_value = mocker.MagicMock() + return mock + + +@pytest.fixture +def file_logger(mocker): + mock = mocker.patch("{}.cmds.search.logger_factory.get_logger_for_file".format(PRODUCT_NAME)) + mock.return_value = mocker.MagicMock() + return mock + + +@pytest.fixture +def cli_state_with_user(sdk_with_user, cli_state): + cli_state.sdk = sdk_with_user + return cli_state + + +@pytest.fixture +def cli_state_without_user(sdk_without_user, cli_state): + cli_state.sdk = sdk_without_user + return cli_state + + +@pytest.fixture +def bad_request_for_user_already_added(mocker): + resp = mocker.MagicMock(spec=Response) + resp.text = "User already on list" + return _create_bad_request_mock(resp) + + +@pytest.fixture +def generic_bad_request(mocker): + resp = mocker.MagicMock(spec=Response) + req = mocker.MagicMock(spec=Request) + req.body = '{"test":"body"}' + resp.request = req + resp.text = "TEST" + return _create_bad_request_mock(resp) + + +def _create_bad_request_mock(resp): + base_err = HTTPError() + base_err.response = resp + return Py42BadRequestError(base_err) + + def get_filter_value_from_json(json, filter_index): return json_module.loads(str(json))["filters"][filter_index]["value"] +def filter_term_is_in_call_args(extractor, term): + arg_filters = extractor.extract.call_args[0] + for f in arg_filters: + if term in str(f): + return True + return False + + def parse_date_from_filter_value(json, filter_index): date_str = get_filter_value_from_json(json, filter_index) return convert_str_to_date(date_str) -ACCEPTABLE_ARGS = [ - "-t", - "SharedToDomain", - "ApplicationRead", - "CloudStorage", - "RemovableMedia", - "IsPublic", - "-f", - "JSON", - "-d", - "-b", - "600", - "-e", - "2020-02-02", - "--c42-username", - "test.testerson", - "--actor", - "test.testerson", - "--md5", - "098f6bcd4621d373cade4e832627b4f6", - "--sha256", - "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", - "--source", - "Gmail", - "--file-name", - "file.txt", - "--file-path", - "/path/to/file.txt", - "--process-owner", - "test.testerson", - "--tab-url", - "https://example.com", - "--include-non-exposure", -] +def thread_safe_side_effect(): + def f(*args): + with threading.Lock(): + f.call_count += 1 + f.call_args_list.append(args) + + f.call_count = 0 + f.call_args_list = [] + return f diff --git a/tests/cmds/detectionlists/__init__.py b/tests/cmds/detectionlists/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/detectionlists/conftest.py b/tests/cmds/detectionlists/conftest.py deleted file mode 100644 index f1afa72ac..000000000 --- a/tests/cmds/detectionlists/conftest.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest -from requests import Response, HTTPError - -from py42.exceptions import Py42BadRequestError - - -@pytest.fixture -def bad_request_for_user_already_added(mocker): - resp = mocker.MagicMock(spec=Response) - resp.text = "User already on list" - return _create_bad_request_mock(resp) - - -@pytest.fixture -def generic_bad_request(mocker): - resp = mocker.MagicMock(spec=Response) - resp.text = "TEST" - return _create_bad_request_mock(resp) - - -def _create_bad_request_mock(resp): - base_err = HTTPError() - base_err.response = resp - return Py42BadRequestError(base_err) diff --git a/tests/cmds/detectionlists/test_bulk.py b/tests/cmds/detectionlists/test_bulk.py deleted file mode 100644 index b56508e67..000000000 --- a/tests/cmds/detectionlists/test_bulk.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest -from code42cli.cmds.detectionlists import DetectionListHandlers -from code42cli.bulk import BulkCommandType -from code42cli.cmds.detectionlists.bulk import ( - BulkHighRiskEmployee, - BulkDetectionList, - HighRiskBulkCommandType, -) - - -def test_bulk_risk_command_type_inheritance(): - risk_tags_command_type = HighRiskBulkCommandType() - assert risk_tags_command_type.ADD == BulkCommandType.ADD - assert risk_tags_command_type.ADD_RISK_TAG == u"add-risk-tags" - - -def test_bulk_detection_list_get_handler_returns_valid_handler(): - handlers = DetectionListHandlers(add=u"x", remove=u"y", load_add=u"z") - bulk_detection_list = BulkDetectionList() - handler = bulk_detection_list.get_handler(handlers, BulkCommandType.ADD) - assert handler == u"x" - - -def test_bulk_high_risk_employee_get_handler_returns_valid_handler(): - handlers = DetectionListHandlers(add=u"x", remove=u"y", load_add=u"z") - handlers.add_handler(u"add_risk_tags", u"p") - handlers.add_handler(u"remove_risk_tags", u"q") - bulk_hre = BulkHighRiskEmployee() - handler = bulk_hre.get_handler(handlers, HighRiskBulkCommandType.ADD_RISK_TAG) - assert handler == u"p" - handler = bulk_hre.get_handler(handlers, HighRiskBulkCommandType.REMOVE_RISK_TAG) - assert handler == u"q" diff --git a/tests/cmds/detectionlists/test_departing_employee.py b/tests/cmds/detectionlists/test_departing_employee.py deleted file mode 100644 index 7ade01944..000000000 --- a/tests/cmds/detectionlists/test_departing_employee.py +++ /dev/null @@ -1,81 +0,0 @@ -import pytest - -from code42cli.errors import UserAlreadyAddedError, UserDoesNotExistError -from code42cli.cmds.detectionlists.departing_employee import ( - add_departing_employee, - remove_departing_employee, - DepartingEmployeeSubcommandLoader, -) - -from ...conftest import TEST_ID - -from py42.exceptions import Py42BadRequestError - - -_EMPLOYEE = "departing employee" - - -class TestDepartingEmployeeSubcommandLoader(object): - def test_load_subcommands_loads_expected_commands(self): - loader = DepartingEmployeeSubcommandLoader("test") - cmds = loader.load_commands() - names = [cmd.name for cmd in cmds] - assert "add" in names - assert "bulk" in names - assert "remove" in names - - def test_loader_has_expected_detection_list_name(self): - loader = DepartingEmployeeSubcommandLoader("test") - assert "departing-employee" == loader.detection_list.name - - -def test_add_departing_employee_when_given_cloud_alias_adds_alias(sdk_with_user, profile): - alias = "departing employee alias" - add_departing_employee(sdk_with_user, profile, _EMPLOYEE, cloud_alias=[alias]) - sdk_with_user.detectionlists.add_user_cloud_alias.assert_called_once_with(TEST_ID, [alias]) - - -def test_add_departing_employee_when_given_notes_updates_notes(sdk_with_user, profile): - notes = "is leaving" - add_departing_employee(sdk_with_user, profile, _EMPLOYEE, notes=notes) - sdk_with_user.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) - - -def test_add_departing_employee_adds(sdk_with_user, profile): - add_departing_employee(sdk_with_user, profile, _EMPLOYEE, departure_date="2020-02-02") - sdk_with_user.detectionlists.departing_employee.add.assert_called_once_with( - TEST_ID, "2020-02-02" - ) - - -def test_add_departing_employee_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(UserDoesNotExistError): - add_departing_employee(sdk_without_user, profile, _EMPLOYEE) - - -def test_add_departing_employee_when_user_already_added_raises_UserAlreadyAddedError( - sdk_with_user, profile, bad_request_for_user_already_added -): - sdk_with_user.detectionlists.departing_employee.add.side_effect = ( - bad_request_for_user_already_added - ) - with pytest.raises(UserAlreadyAddedError): - add_departing_employee(sdk_with_user, profile, _EMPLOYEE) - - -def test_add_departing_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( - sdk_with_user, profile, generic_bad_request, caplog -): - sdk_with_user.detectionlists.departing_employee.add.side_effect = generic_bad_request - with pytest.raises(Py42BadRequestError): - add_departing_employee(sdk_with_user, profile, _EMPLOYEE) - - -def test_remove_departing_employee_calls_remove(sdk_with_user, profile): - remove_departing_employee(sdk_with_user, profile, _EMPLOYEE) - sdk_with_user.detectionlists.departing_employee.remove.assert_called_once_with(TEST_ID) - - -def test_remove_departing_employee_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(UserDoesNotExistError): - remove_departing_employee(sdk_without_user, profile, _EMPLOYEE) diff --git a/tests/cmds/detectionlists/test_high_risk_employee.py b/tests/cmds/detectionlists/test_high_risk_employee.py deleted file mode 100644 index 1546531ab..000000000 --- a/tests/cmds/detectionlists/test_high_risk_employee.py +++ /dev/null @@ -1,122 +0,0 @@ -import pytest - -from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError, UserDoesNotExistError -from code42cli.cmds.detectionlists.high_risk_employee import ( - add_high_risk_employee, - remove_high_risk_employee, - HighRiskEmployeeSubcommandLoader, -) - -from code42cli.cmds.detectionlists.enums import RiskTags -from ...conftest import TEST_ID - -from py42.exceptions import Py42BadRequestError - - -_EMPLOYEE = "risky employee" - - -class TestHighRiskEmployeeSubcommandLoader(object): - def test_load_subcommands_loads_expected_commands(self): - loader = HighRiskEmployeeSubcommandLoader("test") - cmds = loader.load_commands() - names = [cmd.name for cmd in cmds] - assert "add" in names - assert "bulk" in names - assert "remove" in names - - def test_loader_has_expected_detection_list_name(self): - loader = HighRiskEmployeeSubcommandLoader("test") - assert "high-risk-employee" == loader.detection_list.name - - -def test_add_high_risk_employee_when_given_cloud_alias_adds_alias(sdk_with_user, profile): - alias = "risk employee alias" - add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, cloud_alias=alias) - sdk_with_user.detectionlists.add_user_cloud_alias.assert_called_once_with(TEST_ID, alias) - - -def test_add_high_risk_employee_when_given_risk_tags_adds_tags(sdk_with_user, profile): - add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, risk_tag="tag1 tag2 tag3") - sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, ["tag1", "tag2", "tag3"] - ) - - -def test_add_high_risk_employee_when_given_str_of_risk_tags_adds_tags(sdk_with_user, profile): - risk_tag = "BeingRisky" - add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, risk_tag=[risk_tag]) - sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with(TEST_ID, [risk_tag]) - - -def test_add_high_risk_employee_when_given_notes_updates_notes(sdk_with_user, profile): - notes = "being risky" - add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE, notes=notes) - sdk_with_user.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) - - -def test_add_high_risk_employee_adds(sdk_with_user, profile): - add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) - sdk_with_user.detectionlists.high_risk_employee.add.assert_called_once_with(TEST_ID) - - -def test_add_high_risk_employee_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(UserDoesNotExistError): - add_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) - - -def test_add_high_risk_employee_when_user_already_added_raises_UserAlreadyAddedError( - sdk_with_user, profile, bad_request_for_user_already_added -): - sdk_with_user.detectionlists.high_risk_employee.add.side_effect = ( - bad_request_for_user_already_added - ) - with pytest.raises(UserAlreadyAddedError): - add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) - - -def test_add_high_risk_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( - sdk_with_user, profile, generic_bad_request, caplog -): - sdk_with_user.detectionlists.high_risk_employee.add.side_effect = generic_bad_request - with pytest.raises(Py42BadRequestError): - add_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) - - -def test_add_high_risk_employee_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( - sdk_with_user, profile, generic_bad_request -): - sdk_with_user.detectionlists.add_user_risk_tags.side_effect = generic_bad_request - foo = "foo" - bar = "bar" - mysterious_coffee_breaks = "MYSTERIOUS_COFFEE_BREAKS" - try: - add_high_risk_employee( - sdk_with_user, - profile, - _EMPLOYEE, - risk_tag="{} {} {} {} {} {} {}".format( - RiskTags.ELEVATED_ACCESS_PRIVILEGES, - foo, - RiskTags.HIGH_IMPACT_EMPLOYEE, - bar, - mysterious_coffee_breaks, - RiskTags.SUSPICIOUS_SYSTEM_ACTIVITY, - RiskTags.CONTRACT_EMPLOYEE, - ), - ) - except UnknownRiskTagError as err: - err_str = str(err) - assert foo in err_str - assert bar in err_str - assert mysterious_coffee_breaks in err_str - - -def test_remove_high_risk_employee_calls_remove(sdk_with_user, profile): - remove_high_risk_employee(sdk_with_user, profile, _EMPLOYEE) - sdk_with_user.detectionlists.high_risk_employee.remove.assert_called_once_with(TEST_ID) - - -def test_remove_high_risk_employee_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(UserDoesNotExistError): - remove_high_risk_employee(sdk_without_user, profile, _EMPLOYEE) diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py deleted file mode 100644 index 84e379903..000000000 --- a/tests/cmds/detectionlists/test_init.py +++ /dev/null @@ -1,290 +0,0 @@ -import pytest - -from code42cli import PRODUCT_NAME -from code42cli.cmds.detectionlists import ( - try_handle_user_already_added_error, - DetectionList, - DetectionListHandlers, - update_user, - try_add_risk_tags, - try_remove_risk_tags, - add_risk_tags, - remove_risk_tags, -) -from code42cli.errors import UserAlreadyAddedError, UnknownRiskTagError, UserDoesNotExistError -from code42cli.bulk import BulkCommandType -from code42cli.cmds.detectionlists.enums import RiskTags -from code42cli.cmds.detectionlists.bulk import HighRiskBulkCommandType -from ...conftest import create_mock_reader, TEST_ID - - -_NAMESPACE = "{}.cmds.detectionlists".format(PRODUCT_NAME) -_EMPLOYEE = "risky employee" - - -@pytest.fixture -def bulk_template_generator(mocker): - return mocker.patch("{}.generate_template".format(_NAMESPACE)) - - -@pytest.fixture -def bulk_processor(mocker): - return mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) - - -def test_try_handle_user_already_added_error_when_error_indicates_user_added_raises_UserAlreadyAddedError( - bad_request_for_user_already_added, -): - with pytest.raises(UserAlreadyAddedError): - try_handle_user_already_added_error(bad_request_for_user_already_added, "name", "listname") - - -def test_try_handle_user_already_added_error_when_error_does_not_indicate_user_added_returns_false( - generic_bad_request, -): - assert not try_handle_user_already_added_error(generic_bad_request, "name", "listname") - - -def test_update_user_adds_cloud_alias(sdk_with_user, profile): - update_user(sdk_with_user, TEST_ID, cloud_alias="1@example.com") - sdk_with_user.detectionlists.add_user_cloud_alias.assert_called_once_with( - TEST_ID, "1@example.com" - ) - - -def test_update_user_adds_risk_tags(sdk_with_user, profile): - update_user(sdk_with_user, TEST_ID, risk_tag=["rf1", "rf2", "rf3"]) - sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, ["rf1", "rf2", "rf3"] - ) - - -def test_update_user_updates_notes(sdk_with_user, profile): - notes = "notes" - update_user(sdk_with_user, TEST_ID, notes=notes) - sdk_with_user.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) - - -def test_try_add_risk_tags_when_sdk_raises_bad_request_and_given_unknown_tags_raises_UnknownRiskTagError( - sdk, profile, generic_bad_request -): - sdk.detectionlists.add_user_risk_tags.side_effect = generic_bad_request - try: - try_add_risk_tags(sdk, profile, ["foo", RiskTags.SUSPICIOUS_SYSTEM_ACTIVITY, "bar"]) - except UnknownRiskTagError as err: - err_str = str(err) - assert "foo" in err_str - assert "bar" in err_str - - -def test_try_remove_risk_tags_when_sdk_raises_bad_request_and_given_unknown_tags_raises_UnknownRiskTagError( - sdk, profile, generic_bad_request -): - sdk.detectionlists.remove_user_risk_tags.side_effect = generic_bad_request - try: - try_remove_risk_tags(sdk, profile, ["foo", RiskTags.SUSPICIOUS_SYSTEM_ACTIVITY, "bar"]) - except UnknownRiskTagError as err: - err_str = str(err) - assert "foo" in err_str - assert "bar" in err_str - - -class TestDetectionList(object): - def test_create_subcommands_loads_expected_commands(self): - detection_list = DetectionList("TestList", DetectionListHandlers()) - cmds = detection_list.load_subcommands() - assert cmds[0].name == "bulk" - assert cmds[1].name == "add" - assert cmds[2].name == "remove" - - def test_generate_template_file_when_given_add_generates_template_from_handler( - self, bulk_template_generator - ): - def a_test_func(param1, param2, param3): - pass - - handlers = DetectionListHandlers() - handlers.add_employee = a_test_func - detection_list = DetectionList("TestList", handlers) - path = "some/path" - detection_list.generate_template_file(BulkCommandType.ADD, path) - bulk_template_generator.assert_called_once_with(a_test_func, path) - - def test_generate_template_file_when_given_remove_generates_template_from_handler( - self, bulk_template_generator - ): - def a_test_func(): - pass - - handlers = DetectionListHandlers() - handlers.remove_employee = a_test_func - detection_list = DetectionList("TestList", handlers) - path = "some/path" - detection_list.generate_template_file(BulkCommandType.REMOVE, path) - bulk_template_generator.assert_called_once_with(a_test_func, path) - - def test_bulk_add_employees_uses_expected_arguments(self, mocker, sdk, profile, bulk_processor): - reader = create_mock_reader([{"test": "value"}]) - reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) - reader_factory.return_value = reader - detection_list = DetectionList("TestList", DetectionListHandlers()) - detection_list.bulk_add_employees(sdk, profile, "csv_test") - assert bulk_processor.call_args[0][1] == reader - reader_factory.assert_called_once_with("csv_test") - - def test_bulk_remove_employees_uses_expected_arguments( - self, mocker, sdk, profile, bulk_processor - ): - reader = create_mock_reader(["test1", "test2"]) - reader_factory = mocker.patch("{}.create_flat_file_reader".format(_NAMESPACE)) - reader_factory.return_value = reader - detection_list = DetectionList("TestList", DetectionListHandlers()) - detection_list.bulk_remove_employees(sdk, profile, "file_test") - assert bulk_processor.call_args[0][1] == reader - reader_factory.assert_called_once_with("file_test") - - def test_bulk_add_risk_tags_uses_csv_path(self, mocker, sdk, profile, bulk_processor): - reader = create_mock_reader([{"test": "value"}]) - reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) - reader_factory.return_value = reader - detection_list = DetectionList("TestList", DetectionListHandlers()) - detection_list.bulk_add_risk_tags(sdk, profile, "csv_test") - assert bulk_processor.call_args[0][1] == reader - reader_factory.assert_called_once_with("csv_test") - - def test_bulk_remove_risk_tags_uses_csv_path(self, mocker, sdk, profile, bulk_processor): - reader = create_mock_reader([{"test": "value"}]) - reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) - reader_factory.return_value = reader - detection_list = DetectionList("TestList", DetectionListHandlers()) - detection_list.bulk_remove_risk_tags(sdk, profile, "file_test") - assert bulk_processor.call_args[0][1] == reader - reader_factory.assert_called_once_with("file_test") - - def test_generate_template_file_when_given_add_risk_tags_generates_template_from_handler( - self, bulk_template_generator - ): - def a_test_func(): - pass - - handlers = DetectionListHandlers() - handlers.add_handler("add_risk_tags", a_test_func) - detection_list = DetectionList.create_high_risk_employee_list(handlers) - path = "some/path" - detection_list.generate_template_file(HighRiskBulkCommandType.ADD_RISK_TAG, path) - bulk_template_generator.assert_called_once_with(a_test_func, path) - - def test_generate_template_file_when_given_remove_risk_tags_generates_template_from_handler( - self, bulk_template_generator - ): - def a_test_func(): - pass - - handlers = DetectionListHandlers() - handlers.add_handler("remove_risk_tags", a_test_func) - detection_list = DetectionList.create_high_risk_employee_list(handlers) - path = "some/path" - detection_list.generate_template_file(HighRiskBulkCommandType.REMOVE_RISK_TAG, path) - bulk_template_generator.assert_called_once_with(a_test_func, path) - - -def test_add_risk_tags_adds_tags(sdk_with_user, profile): - add_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] - ) - - -def test_add_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): - add_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - "{} {}".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), - ) - sdk_with_user.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] - ) - - -def test_add_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(UserDoesNotExistError): - add_risk_tags( - sdk_without_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - - -def test_add_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( - sdk_with_user, profile, generic_bad_request -): - sdk_with_user.detectionlists.add_user_risk_tags.side_effect = generic_bad_request - try: - add_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - "{} foo {} bar".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), - ) - except UnknownRiskTagError as err: - err_str = str(err) - assert "foo" in err_str - assert "bar" in err_str - - -def test_remove_risk_tags_adds_tags(sdk_with_user, profile): - remove_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( - TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] - ) - - -def test_remove_risk_tags_when_given_space_delimited_str_adds_expected_tags(sdk_with_user, profile): - remove_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - "{} {}".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), - ) - sdk_with_user.detectionlists.remove_user_risk_tags.assert_called_once_with( - TEST_ID, [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK] - ) - - -def test_remove_risk_tags_when_user_does_not_exist_exits(sdk_without_user, profile): - with pytest.raises(UserDoesNotExistError): - remove_risk_tags( - sdk_without_user, - profile, - _EMPLOYEE, - [RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK], - ) - - -def test_remove_risk_tags_when_bad_request_and_unknown_risk_tags_raises_UnknownRiskTagError( - sdk_with_user, profile, generic_bad_request -): - sdk_with_user.detectionlists.remove_user_risk_tags.side_effect = generic_bad_request - try: - remove_risk_tags( - sdk_with_user, - profile, - _EMPLOYEE, - "{} foo {} bar".format(RiskTags.ELEVATED_ACCESS_PRIVILEGES, RiskTags.FLIGHT_RISK), - ) - except UnknownRiskTagError as err: - err_str = str(err) - assert "foo" in err_str - assert "bar" in err_str diff --git a/tests/cmds/legal_hold/__init__.py b/tests/cmds/legal_hold/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/legal_hold/test_legal_hold.py b/tests/cmds/legal_hold/test_legal_hold.py deleted file mode 100644 index ef6e2ad4a..000000000 --- a/tests/cmds/legal_hold/test_legal_hold.py +++ /dev/null @@ -1,271 +0,0 @@ -import pytest - -from requests import Response, HTTPError - -from code42cli import PRODUCT_NAME -from code42cli.cmds.legal_hold import ( - add_user, - remove_user, - show_matter, - add_bulk_users, - remove_bulk_users, - _check_matter_is_accessible, -) -from code42cli.errors import ( - UserAlreadyAddedError, - UserNotInLegalHoldError, - LegalHoldNotFoundOrPermissionDeniedError, - UserDoesNotExistError, -) - -_NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) - -from py42.exceptions import Py42BadRequestError -from py42.response import Py42Response -from requests import Response - -from ...conftest import create_mock_reader - -TEST_MATTER_ID = "99999" -TEST_LEGAL_HOLD_MEMBERSHIP_UID = "88888" -TEST_LEGAL_HOLD_MEMBERSHIP_UID_2 = "77777" -ACTIVE_TEST_USERNAME = "user@example.com" -ACTIVE_TEST_USER_ID = "12345" -INACTIVE_TEST_USERNAME = "inactive@example.com" -INACTIVE_TEST_USER_ID = "54321" - -TEST_POLICY_UID = "66666" - -TEST_MATTER_RESULT = { - "legalHoldUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID, - "name": "Test_Matter", - "description": "", - "active": True, - "creationDate": "2020-01-01T00:00:00.000-06:00", - "creator": {"userUid": "942564422882759874", "username": "legal_admin@example.com"}, - "holdPolicyUid": TEST_POLICY_UID, -} - -ACTIVE_LEGAL_HOLD_MEMBERSHIP = { - "legalHoldMembershipUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID, - "user": {"userUid": ACTIVE_TEST_USER_ID, "username": ACTIVE_TEST_USERNAME}, - "active": True, -} -INACTIVE_LEGAL_HOLD_MEMBERSHIP = { - "legalHoldMembershipUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID_2, - "user": {"userUid": INACTIVE_TEST_USER_ID, "username": INACTIVE_TEST_USERNAME}, - "active": False, -} - - -EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": []}] -ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP]}] -ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ - {"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP, INACTIVE_LEGAL_HOLD_MEMBERSHIP]} -] -INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ - {"legalHoldMemberships": [INACTIVE_LEGAL_HOLD_MEMBERSHIP]} -] - -TEST_PRESERVATION_POLICY_UID = "1010101010" -TEST_PRESERVATION_POLICY_JSON = '{{"creationDate": "2020-01-01","legalHoldPolicyUid": {}}}'.format( - TEST_PRESERVATION_POLICY_UID -) - - -@pytest.fixture -def preservation_policy_response(mocker): - response = mocker.MagicMock(spec=Response) - response.text = TEST_PRESERVATION_POLICY_JSON - return Py42Response(response) - - -@pytest.fixture -def get_user_id_success(sdk): - sdk.users.get_by_username.return_value = {"users": [{"userUid": ACTIVE_TEST_USER_ID}]} - - -@pytest.fixture -def get_user_id_failure(sdk): - sdk.users.get_by_username.return_value = {"users": []} - - -@pytest.fixture -def check_matter_accessible_success(sdk): - sdk.legalhold.get_matter_by_uid.return_value = TEST_MATTER_RESULT - - -@pytest.fixture -def check_matter_accessible_failure(sdk): - sdk.legalhold.get_matter_by_uid.side_effect = Py42BadRequestError(HTTPError()) - - -@pytest.fixture -def user_already_added_response(mocker): - mock_response = mocker.MagicMock(spec=Response) - mock_response.text = "USER_ALREADY_IN_HOLD" - http_error = HTTPError() - http_error.response = mock_response - return Py42BadRequestError(http_error) - - -def test_add_user_raises_user_already_added_error_when_user_already_on_hold( - sdk, user_already_added_response -): - sdk.legalhold.add_to_matter.side_effect = user_already_added_response - with pytest.raises(UserAlreadyAddedError): - add_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) - - -def test_add_user_raises_legalhold_not_found_error_if_matter_inaccessible( - sdk, check_matter_accessible_failure, get_user_id_success -): - with pytest.raises(LegalHoldNotFoundOrPermissionDeniedError): - add_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) - - -def test_add_user_adds_user_to_hold_if_user_and_matter_exist( - sdk, check_matter_accessible_success, get_user_id_success -): - add_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) - sdk.legalhold.add_to_matter.assert_called_once_with(ACTIVE_TEST_USER_ID, TEST_MATTER_ID) - - -def test_remove_user_raises_legalhold_not_found_error_if_matter_inaccessible( - sdk, check_matter_accessible_failure, get_user_id_success -): - with pytest.raises(LegalHoldNotFoundOrPermissionDeniedError): - remove_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) - - -def test_remove_user_raises_user_not_in_matter_error_if_user_not_active_in_matter( - sdk, check_matter_accessible_success, get_user_id_success -): - sdk.legalhold.get_all_matter_custodians.return_value = EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT - with pytest.raises(UserNotInLegalHoldError): - remove_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) - - -def test_remove_user_removes_user_if_user_in_matter( - sdk, check_matter_accessible_success, get_user_id_success -): - sdk.legalhold.get_all_matter_custodians.return_value = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT - membership_uid = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT[0]["legalHoldMemberships"][0][ - "legalHoldMembershipUid" - ] - remove_user(sdk, TEST_MATTER_ID, ACTIVE_TEST_USERNAME) - sdk.legalhold.remove_from_matter.assert_called_with(membership_uid) - - -def test_matter_accessible_check_only_makes_one_http_call_when_called_multiple_times_with_same_matter_id( - sdk, check_matter_accessible_success -): - _check_matter_is_accessible(sdk, TEST_MATTER_ID) - _check_matter_is_accessible(sdk, TEST_MATTER_ID) - _check_matter_is_accessible(sdk, TEST_MATTER_ID) - _check_matter_is_accessible(sdk, TEST_MATTER_ID) - assert sdk.legalhold.get_matter_by_uid.call_count == 1 - - -def test_show_matter_prints_active_and_inactive_results_when_include_inactive_flag_set( - sdk, check_matter_accessible_success, capsys -): - sdk.legalhold.get_all_matter_custodians.return_value = ( - ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT - ) - show_matter(sdk, TEST_MATTER_ID, include_inactive=True) - capture = capsys.readouterr() - assert ACTIVE_TEST_USERNAME in capture.out - assert INACTIVE_TEST_USERNAME in capture.out - - -def test_show_matter_prints_active_results_only(sdk, check_matter_accessible_success, capsys): - sdk.legalhold.get_all_matter_custodians.return_value = ( - ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT - ) - show_matter(sdk, TEST_MATTER_ID) - capture = capsys.readouterr() - assert ACTIVE_TEST_USERNAME in capture.out - assert INACTIVE_TEST_USERNAME not in capture.out - - -def test_show_matter_prints_no_active_members_when_no_membership( - sdk, check_matter_accessible_success, capsys -): - sdk.legalhold.get_all_matter_custodians.return_value = EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT - show_matter(sdk, TEST_MATTER_ID) - capture = capsys.readouterr() - assert ACTIVE_TEST_USERNAME not in capture.out - assert INACTIVE_TEST_USERNAME not in capture.out - assert "No active matter members." in capture.out - - -def test_show_matter_prints_no_inactive_members_when_no_inactive_membership( - sdk, check_matter_accessible_success, capsys -): - sdk.legalhold.get_all_matter_custodians.return_value = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT - show_matter(sdk, TEST_MATTER_ID, include_inactive=True) - capture = capsys.readouterr() - assert ACTIVE_TEST_USERNAME in capture.out - assert INACTIVE_TEST_USERNAME not in capture.out - assert "No inactive matter members." in capture.out - - -def test_show_matter_prints_no_active_members_when_no_active_membership( - sdk, check_matter_accessible_success, capsys -): - sdk.legalhold.get_all_matter_custodians.return_value = INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT - show_matter(sdk, TEST_MATTER_ID, include_inactive=True) - capture = capsys.readouterr() - assert ACTIVE_TEST_USERNAME not in capture.out - assert INACTIVE_TEST_USERNAME in capture.out - assert "No active matter members." in capture.out - - -def test_show_matter_prints_no_active_members_when_no_active_membership_and_inactive_membership_included( - sdk, check_matter_accessible_success, capsys -): - sdk.legalhold.get_all_matter_custodians.return_value = INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT - show_matter(sdk, TEST_MATTER_ID, include_inactive=True) - capture = capsys.readouterr() - assert ACTIVE_TEST_USERNAME not in capture.out - assert INACTIVE_TEST_USERNAME in capture.out - assert "No active matter members." in capture.out - - -def test_show_matter_prints_preservation_policy_when_include_policy_flag_set( - sdk, check_matter_accessible_success, preservation_policy_response, capsys -): - sdk.legalhold.get_policy_by_uid.return_value = preservation_policy_response - show_matter(sdk, TEST_MATTER_ID, include_policy=True) - capture = capsys.readouterr() - assert TEST_PRESERVATION_POLICY_UID in capture.out - - -def test_show_matter_does_not_print_preservation_policy( - sdk, check_matter_accessible_success, preservation_policy_response, capsys -): - sdk.legalhold.get_policy_by_uid.return_value = preservation_policy_response - show_matter(sdk, TEST_MATTER_ID) - capture = capsys.readouterr() - assert TEST_PRESERVATION_POLICY_UID not in capture.out - - -def test_add_bulk_users_uses_expected_arguments(mocker, sdk, profile): - reader = create_mock_reader([{"test": "value"}]) - bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) - reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) - reader_factory.return_value = reader - add_bulk_users(sdk, "csv_test") - assert bulk_processor.call_args[0][1] == reader - reader_factory.assert_called_once_with("csv_test") - - -def test_remove_bulk_users_uses_expected_arguments(mocker, sdk, profile): - reader = create_mock_reader([{"test": "value"}]) - bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) - reader_factory = mocker.patch("{}.create_csv_reader".format(_NAMESPACE)) - reader_factory.return_value = reader - remove_bulk_users(sdk, "csv_test") - assert bulk_processor.call_args[0][1] == reader - reader_factory.assert_called_once_with("csv_test") diff --git a/tests/cmds/search_shared/__init__.py b/tests/cmds/search_shared/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/search_shared/test_advanced_query_args.py b/tests/cmds/search_shared/test_advanced_query_args.py deleted file mode 100644 index 35abb6f30..000000000 --- a/tests/cmds/search_shared/test_advanced_query_args.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -from code42cli.parser import ( - exit_if_mutually_exclusive_args_used_together, -) -from code42cli.cmds.search_shared.args import create_incompatible_search_args - - -def test_exit_if_advanced_query_provided_incompatible_args( - file_event_namespace, alert_namespace -): - file_event_namespace.advanced_query = "Not None" - file_event_namespace.begin = "value" - with pytest.raises(SystemExit): - exit_if_mutually_exclusive_args_used_together( - file_event_namespace, - list(create_incompatible_search_args().keys()) - ) - - alert_namespace.advanced_query = "Not None" - alert_namespace.begin = "value" - with pytest.raises(SystemExit): - exit_if_mutually_exclusive_args_used_together( - alert_namespace, - list(create_incompatible_search_args().keys()) - ) diff --git a/tests/cmds/search_shared/test_date_helper.py b/tests/cmds/search_shared/test_date_helper.py deleted file mode 100644 index 69678cb16..000000000 --- a/tests/cmds/search_shared/test_date_helper.py +++ /dev/null @@ -1,147 +0,0 @@ -import pytest - -from code42cli.errors import DateArgumentError -from code42cli.cmds.search_shared.extraction import create_time_range_filter -from py42.sdk.queries.fileevents.filters import InsertionTimestamp, EventTimestamp -from py42.sdk.queries.alerts.filters import DateObserved -from tests.cmds.conftest import get_filter_value_from_json -from tests.conftest import ( - begin_date_str, - begin_date_with_time, - end_date_str, - end_date_with_time, - get_test_date_str, -) - -timestamp_filter_list = [InsertionTimestamp, EventTimestamp, DateObserved] - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_given_nothing_returns_none(timestamp_filter_class): - ts_range = create_time_range_filter(timestamp_filter_class) - assert not ts_range - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_given_nones_returns_none(timestamp_filter_class): - ts_range = create_time_range_filter(timestamp_filter_class, None, None) - assert not ts_range - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_builds_expected_query(timestamp_filter_class): - ts_range = create_time_range_filter(timestamp_filter_class, begin_date_str) - actual = get_filter_value_from_json(ts_range, filter_index=0) - expected = "{0}T00:00:00.000Z".format(begin_date_str) - assert actual == expected - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_given_begin_with_time_builds_expected_query( - timestamp_filter_class, -): - time_str = u"{} {}".format(*begin_date_with_time) - ts_range = create_time_range_filter(timestamp_filter_class, time_str) - actual = get_filter_value_from_json(ts_range, filter_index=0) - expected = "{0}T0{1}.000Z".format(*begin_date_with_time) - assert actual == expected - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_given_end_builds_expected_query(timestamp_filter_class): - ts_range = create_time_range_filter(timestamp_filter_class, begin_date_str, end_date_str) - actual = get_filter_value_from_json(ts_range, filter_index=1) - expected = "{0}T23:59:59.999Z".format(end_date_str) - assert actual == expected - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_given_end_with_time_builds_expected_query( - timestamp_filter_class, -): - end_date_str = "{} {}".format(*end_date_with_time) - ts_range = create_time_range_filter(timestamp_filter_class, begin_date_str, end_date_str) - actual = get_filter_value_from_json(ts_range, filter_index=1) - expected = "{0}T{1}.000Z".format(*end_date_with_time) - assert actual == expected - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_given_both_begin_and_end_builds_expected_query( - timestamp_filter_class, -): - end_date = "{} {}".format(*end_date_with_time) - ts_range = create_time_range_filter(timestamp_filter_class, begin_date_str, end_date) - actual_begin = get_filter_value_from_json(ts_range, filter_index=0) - actual_end = get_filter_value_from_json(ts_range, filter_index=1) - expected_begin = "{0}T00:00:00.000Z".format(begin_date_str) - expected_end = "{0}T{1}.000Z".format(*end_date_with_time) - assert actual_begin == expected_begin - assert actual_end == expected_end - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_given_short_time_args_builds_expected_query( - timestamp_filter_class, -): - begin_date = "{} 10".format(begin_date_str) - end_date = "{} 12:37".format(end_date_str) - ts_range = create_time_range_filter(timestamp_filter_class, begin_date, end_date) - actual_begin = get_filter_value_from_json(ts_range, filter_index=0) - actual_end = get_filter_value_from_json(ts_range, filter_index=1) - expected_begin = "{0}T10:00:00.000Z".format(begin_date_str) - expected_end = "{0}T12:37:00.000Z".format(end_date_str) - assert actual_begin == expected_begin - assert actual_end == expected_end - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_begin_more_than_ninety_days_back_causes_value_error( - timestamp_filter_class, -): - begin_date_str = get_test_date_str(days_ago=91) - with pytest.raises(DateArgumentError): - create_time_range_filter(timestamp_filter_class, begin_date_str) - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_end_is_before_begin_causes_value_error( - timestamp_filter_class, -): - begin_date = get_test_date_str(days_ago=5) - end_date = get_test_date_str(days_ago=7) - with pytest.raises(DateArgumentError): - create_time_range_filter(timestamp_filter_class, begin_date, end_date) - - -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_args_are_magic_days_builds_expected_query( - timestamp_filter_class, -): - begin_magic_str = "10d" - end_magic_str = "6d" - ts_range = create_time_range_filter(timestamp_filter_class, begin_magic_str, end_magic_str) - actual_begin = get_filter_value_from_json(ts_range, filter_index=0) - expected_begin = "{}T00:00:00.000Z".format(get_test_date_str(days_ago=10)) - actual_end = get_filter_value_from_json(ts_range, filter_index=1) - expected_end = "{}T23:59:59.999Z".format(get_test_date_str(days_ago=6)) - assert actual_begin == expected_begin - assert actual_end == expected_end - - -@pytest.mark.parametrize( - "bad_date_param", - [ - "01-01-2020", - "{} {}".format(get_test_date_str(days_ago=5), "b20:30:00"), - "2months", - "100s", - "10 d", - ], -) -@pytest.mark.parametrize("timestamp_filter_class", timestamp_filter_list) -def test_create_event_timestamp_filter_when_given_improperly_formatted_arg_raises_value_error( - bad_date_param, timestamp_filter_class -): - with pytest.raises(DateArgumentError): - create_time_range_filter(timestamp_filter_class, bad_date_param) diff --git a/tests/cmds/securitydata/__init__.py b/tests/cmds/securitydata/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/securitydata/savedsearch/__init__.py b/tests/cmds/securitydata/savedsearch/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/securitydata/savedsearch/test_commands.py b/tests/cmds/securitydata/savedsearch/test_commands.py deleted file mode 100644 index 3d01e2f55..000000000 --- a/tests/cmds/securitydata/savedsearch/test_commands.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest -from code42cli.cmds.securitydata.savedsearch.commands import SavedSearchSubCommandLoader - - -class TestSavedSearchSubCommandLoader(object): - - def test_load_commands_loads_expected_commands(self): - loader = SavedSearchSubCommandLoader("Test") - commands = loader.load_commands() - names = [command.name for command in commands] - assert set(names).issubset( - [ - SavedSearchSubCommandLoader.LIST, - SavedSearchSubCommandLoader.SHOW, - ] - ) - - diff --git a/tests/cmds/securitydata/savedsearch/test_savedsearch.py b/tests/cmds/securitydata/savedsearch/test_savedsearch.py deleted file mode 100644 index ee486c732..000000000 --- a/tests/cmds/securitydata/savedsearch/test_savedsearch.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest -from code42cli.cmds.securitydata.savedsearch.savedsearch import ( - show, - show_detail -) - - -def test_show_calls_get_method(sdk_with_user, profile): - show(sdk_with_user, profile) - assert sdk_with_user.securitydata.savedsearches.get.call_count == 1 - - -def test_show_detail_calls_get_by_id_method(sdk_with_user, profile): - show_detail(sdk_with_user, profile, u"test-id") - sdk_with_user.securitydata.savedsearches.get_by_id.assert_called_once_with(u"test-id") diff --git a/tests/cmds/securitydata/test_extraction.py b/tests/cmds/securitydata/test_extraction.py deleted file mode 100644 index dcce65be7..000000000 --- a/tests/cmds/securitydata/test_extraction.py +++ /dev/null @@ -1,474 +0,0 @@ -import pytest -import logging - -from py42.sdk.queries.fileevents.filters import * -from py42.sdk.queries.fileevents.file_event_query import FileEventQuery - -import code42cli.cmds.securitydata.extraction as extraction_module -import code42cli.errors as errors -from code42cli import PRODUCT_NAME -from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions -from tests.cmds.conftest import get_filter_value_from_json -from code42cli.errors import DateArgumentError -from ...conftest import get_test_date_str, begin_date_str, ErrorTrackerTestHelper - - -@pytest.fixture -def file_event_extractor(mocker): - mock = mocker.MagicMock() - mock.extract_advanced = mocker.patch( - "c42eventextractor.extractors.FileEventExtractor.extract_advanced" - ) - mock.extract = mocker.patch("c42eventextractor.extractors.FileEventExtractor.extract") - return mock - - -@pytest.fixture -def file_event_namespace_with_begin(file_event_namespace): - file_event_namespace.begin = begin_date_str - return file_event_namespace - - -@pytest.fixture -def file_event_checkpoint(mocker): - return mocker.patch( - "{}.cmds.search_shared.cursor_store.FileEventCursorStore.get".format(PRODUCT_NAME) - ) - - -def filter_term_is_in_call_args(extractor, term): - arg_filters = extractor.extract.call_args[0] - for f in arg_filters: - if term in str(f): - return True - return False - - -def test_extract_when_is_advanced_query_uses_only_the_extract_advanced( - sdk, profile, logger, file_event_namespace, file_event_extractor -): - file_event_namespace.advanced_query = "some complex json" - extraction_module.extract(sdk, profile, logger, file_event_namespace) - file_event_extractor.extract_advanced.assert_called_once_with("some complex json") - assert file_event_extractor.extract.call_count == 0 - - -def test_extract_when_is_advanced_query_and_include_non_exposure_is_false_does_not_exit( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.include_non_exposure = False - file_event_namespace.advanced_query = "some complex json" - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_is_advanced_query_and_has_does_not_use_checkpoint_does_not_exit( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.use_checkpoint = None - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_is_not_advanced_query_uses_only_extract_method( - sdk, profile, logger, file_event_extractor, file_event_namespace_with_begin -): - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert file_event_extractor.extract.call_count == 1 - assert file_event_extractor.extract_raw.call_count == 0 - - -def test_extract_when_not_given_begin_or_advanced_causes_exit( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.begin = None - file_event_namespace.advanced_query = None - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_given_begin_date_uses_expected_query( - sdk, profile, logger, file_event_namespace, file_event_extractor -): - file_event_namespace.begin = get_test_date_str(days_ago=89) - extraction_module.extract(sdk, profile, logger, file_event_namespace) - actual = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][0], filter_index=0 - ) - expected = "{0}T00:00:00.000Z".format(file_event_namespace.begin) - assert actual == expected - - -def test_extract_when_given_begin_date_and_time_uses_expected_query( - sdk, profile, logger, file_event_namespace, file_event_extractor -): - date = get_test_date_str(days_ago=89) - time = "15:33:02" - file_event_namespace.begin = get_test_date_str(days_ago=89) + " " + time - extraction_module.extract(sdk, profile, logger, file_event_namespace) - actual = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][0], filter_index=0 - ) - expected = "{0}T{1}.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_given_end_date_uses_expected_query( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.end = get_test_date_str(days_ago=10) - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - actual = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][0], filter_index=1 - ) - expected = "{0}T23:59:59.999Z".format(file_event_namespace_with_begin.end) - assert actual == expected - - -def test_extract_when_given_end_date_and_time_uses_expected_query( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - date = get_test_date_str(days_ago=10) - time = "12:00:11" - file_event_namespace_with_begin.end = date + " " + time - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - actual = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][0], filter_index=1 - ) - expected = "{0}T{1}.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_given_end_date_and_time_without_seconds_uses_expected_query( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - date = get_test_date_str(days_ago=10) - time = "12:00" - file_event_namespace_with_begin.end = date + " " + time - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - actual = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][0], filter_index=1 - ) - expected = "{0}T{1}:00.000Z".format(date, time) - assert actual == expected - - -def test_extract_when_using_both_min_and_max_dates_uses_expected_timestamps( - sdk, profile, logger, file_event_namespace, file_event_extractor -): - end_date = get_test_date_str(days_ago=55) - end_time = "13:44:44" - file_event_namespace.begin = get_test_date_str(days_ago=89) - file_event_namespace.end = end_date + " " + end_time - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - actual_begin_timestamp = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][0], filter_index=0 - ) - actual_end_timestamp = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][0], filter_index=1 - ) - expected_begin_timestamp = "{0}T00:00:00.000Z".format(file_event_namespace.begin) - expected_end_timestamp = "{0}T{1}.000Z".format(end_date, end_time) - - assert actual_begin_timestamp == expected_begin_timestamp - assert actual_end_timestamp == expected_end_timestamp - - -def test_extract_when_given_min_timestamp_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.use_checkpoint = None - date = get_test_date_str(days_ago=91) + " 12:51:00" - file_event_namespace.begin = date - with pytest.raises(DateArgumentError): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_end_date_is_before_begin_date_causes_exit( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.begin = get_test_date_str(days_ago=5) - file_event_namespace.end = get_test_date_str(days_ago=6) - with pytest.raises(DateArgumentError): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_when_given_begin_date_past_90_days_and_uses_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - sdk, profile, logger, file_event_namespace, file_event_extractor, file_event_checkpoint -): - file_event_namespace.begin = "2019-01-01" - file_event_namespace.use_checkpoint = "foo" - file_event_checkpoint.return_value = 22624624 - extraction_module.extract(sdk, profile, logger, file_event_namespace) - assert not filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) - - -def test_when_given_begin_date_and_not_interactive_mode_and_cursor_exists_uses_begin_date( - sdk, profile, logger, file_event_namespace, file_event_extractor, file_event_checkpoint -): - file_event_namespace.begin = get_test_date_str(days_ago=1) - file_event_namespace.use_checkpoint = None - file_event_checkpoint.return_value = 22624624 - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - actual_ts = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][0], filter_index=0 - ) - expected_ts = "{0}T00:00:00.000Z".format(file_event_namespace.begin) - assert actual_ts == expected_ts - assert filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) - - -def test_when_not_given_begin_date_and_uses_checkpoint_but_no_stored_checkpoint_exists_causes_exit( - sdk, profile, logger, file_event_namespace, file_event_checkpoint -): - file_event_namespace.begin = None - file_event_namespace.use_checkpoint = "foo" - file_event_checkpoint.return_value = None - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_given_invalid_exposure_type_causes_exit( - sdk, profile, logger, file_event_namespace -): - file_event_namespace.type = [ - ExposureTypeOptions.APPLICATION_READ, - "SomethingElseThatIsNotSupported", - ExposureTypeOptions.IS_PUBLIC, - ] - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_given_username_uses_username_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.c42_username = ["test.testerson@example.com"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - DeviceUsername.is_in(file_event_namespace_with_begin.c42_username) - ) - - -def test_extract_when_given_actor_uses_actor_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.actor = ["test.testerson"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - Actor.is_in(file_event_namespace_with_begin.actor) - ) - - -def test_extract_when_given_md5_uses_md5_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.md5 = ["098f6bcd4621d373cade4e832627b4f6"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - MD5.is_in(file_event_namespace_with_begin.md5) - ) - - -def test_extract_when_given_sha256_uses_sha256_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.sha256 = [ - "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" - ] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - SHA256.is_in(file_event_namespace_with_begin.sha256) - ) - - -def test_extract_when_given_source_uses_source_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.source = ["Gmail", "Yahoo"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - Source.is_in(file_event_namespace_with_begin.source) - ) - - -def test_extract_when_given_file_name_uses_file_name_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.file_name = ["file.txt", "txt.file"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - FileName.is_in(file_event_namespace_with_begin.file_name) - ) - - -def test_extract_when_given_file_path_uses_file_path_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.file_path = ["/path/to/file.txt", "path2"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - FilePath.is_in(file_event_namespace_with_begin.file_path) - ) - - -def test_extract_when_given_process_owner_uses_process_owner_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.process_owner = ["test.testerson", "another"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - ProcessOwner.is_in(file_event_namespace_with_begin.process_owner) - ) - - -def test_extract_when_given_tab_url_uses_process_tab_url_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.tab_url = ["https://www.example.com"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - TabURL.is_in(file_event_namespace_with_begin.tab_url) - ) - - -def test_extract_when_given_exposure_types_uses_exposure_type_is_in_filter( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.type = ["ApplicationRead", "RemovableMedia", "CloudStorage"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - ExposureType.is_in(file_event_namespace_with_begin.type) - ) - - -def test_extract_when_given_include_non_exposure_does_not_include_exposure_type_exists( - mocker, sdk, profile, logger, file_event_namespace_with_begin -): - file_event_namespace_with_begin.include_non_exposure = True - ExposureType.exists = mocker.MagicMock() - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert not ExposureType.exists.call_count - - -def test_extract_when_not_given_include_non_exposure_includes_exposure_type_exists( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.include_non_exposure = False - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str(ExposureType.exists()) - - -def test_extract_when_given_multiple_search_args_uses_expected_filters( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor -): - file_event_namespace_with_begin.file_path = ["/path/to/file.txt"] - file_event_namespace_with_begin.process_owner = ["test.testerson", "flag.flagerson"] - file_event_namespace_with_begin.tab_url = ["https://www.example.com"] - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert str(file_event_extractor.extract.call_args[0][1]) == str( - FilePath.is_in(file_event_namespace_with_begin.file_path) - ) - assert str(file_event_extractor.extract.call_args[0][2]) == str( - ProcessOwner.is_in(file_event_namespace_with_begin.process_owner) - ) - assert str(file_event_extractor.extract.call_args[0][3]) == str( - TabURL.is_in(file_event_namespace_with_begin.tab_url) - ) - - -def test_extract_when_given_include_non_exposure_and_exposure_types_causes_exit( - sdk, profile, logger, file_event_namespace_with_begin -): - file_event_namespace_with_begin.type = ["ApplicationRead", "RemovableMedia", "CloudStorage"] - file_event_namespace_with_begin.include_non_exposure = True - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - - -def test_extract_when_creating_sdk_throws_causes_exit( - sdk, profile, logger, file_event_namespace, mock_42 -): - def side_effect(): - raise Exception() - - mock_42.side_effect = side_effect - with pytest.raises(SystemExit): - extraction_module.extract(sdk, profile, logger, file_event_namespace) - - -def test_extract_when_not_errored_and_does_not_log_error_occurred( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor, caplog -): - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - with caplog.at_level(logging.ERROR): - assert "View exceptions that occurred at" not in caplog.text - - -def test_extract_when_not_errored_and_is_interactive_does_not_print_error( - sdk, profile, logger, file_event_namespace_with_begin, file_event_extractor, cli_logger, mocker -): - errors.ERRORED = False - mocker.patch("code42cli.cmds.securitydata.extraction.logger", cli_logger) - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert cli_logger.print_and_log_error.call_count == 0 - assert cli_logger.log_error.call_count == 0 - errors.ERRORED = False - - -def test_when_sdk_raises_exception_global_variable_gets_set( - mocker, sdk, profile, logger, file_event_namespace_with_begin, mock_42 -): - errors.ERRORED = False - mock_sdk = mocker.MagicMock() - - def sdk_side_effect(self, *args): - raise Exception() - - mock_sdk.security.search_file_events.side_effect = sdk_side_effect - mock_42.return_value = mock_sdk - - mocker.patch("c42eventextractor.extractors.BaseExtractor._verify_filter_groups") - with ErrorTrackerTestHelper(): - extraction_module.extract(sdk, profile, logger, file_event_namespace_with_begin) - assert errors.ERRORED - - -def test_extract_saved_search_calls_extractor_extract_and_saved_search_execute( - sdk_with_user, profile, logger, file_event_extractor, file_event_namespace_with_begin -): - search_query = { - "groupClause": "AND", - "groups": [ - { - "filterClause": "AND", - "filters": [ - { - "operator": "ON_OR_AFTER", - "term": "eventTimestamp", - "value": "2020-05-01T00:00:00.000Z", - } - ], - }, - { - "filterClause": "OR", - "filters": [ - {"operator": "IS", "term": "eventType", "value": "DELETED"}, - {"operator": "IS", "term": "eventType", "value": "EMAILED"}, - {"operator": "IS", "term": "eventType", "value": "MODIFIED"}, - {"operator": "IS", "term": "eventType", "value": "READ_BY_AP"}, - {"operator": "IS", "term": "eventType", "value": "CREATED"}, - ], - }, - ], - "pgNum": 1, - "pgSize": 10000, - "srtDir": "asc", - "srtKey": "eventId", - } - query = FileEventQuery.from_dict(search_query) - extraction_module.extract( - sdk_with_user, profile, logger, file_event_namespace_with_begin, query - ) - assert file_event_extractor.extract.call_count == 1 diff --git a/tests/cmds/securitydata/test_main.py b/tests/cmds/securitydata/test_main.py deleted file mode 100644 index 6b67acbcd..000000000 --- a/tests/cmds/securitydata/test_main.py +++ /dev/null @@ -1,179 +0,0 @@ -import pytest - -import code42cli.cmds.securitydata.main as main -from code42cli import PRODUCT_NAME -from code42cli.cmds.search_shared.enums import ExposureType as ExposureTypeOptions - - -@pytest.fixture -def mock_logger_factory(mocker): - return mocker.patch("{}.cmds.securitydata.main.logger_factory".format(PRODUCT_NAME)) - - -@pytest.fixture -def mock_extract(mocker): - return mocker.patch("{}.cmds.securitydata.main.extract".format(PRODUCT_NAME)) - - -def test_print_out(sdk, profile, file_event_namespace, mocker, mock_logger_factory, mock_extract): - logger = mocker.MagicMock() - mock_logger_factory.get_logger_for_stdout.return_value = logger - main.print_out(sdk, profile, file_event_namespace) - mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace, None) - - -def test_write_to(sdk, profile, file_event_namespace, mocker, mock_logger_factory, mock_extract): - logger = mocker.MagicMock() - mock_logger_factory.get_logger_for_file.return_value = logger - main.write_to(sdk, profile, file_event_namespace) - mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace, None) - - -def test_send_to(sdk, profile, file_event_namespace, mocker, mock_logger_factory, mock_extract): - logger = mocker.MagicMock() - mock_logger_factory.get_logger_for_server.return_value = logger - main.send_to(sdk, profile, file_event_namespace) - mock_extract.assert_called_with(sdk, profile, logger, file_event_namespace, None) - - -def test_validation_when_is_advanced_query_and_has_begin_date_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.begin = "begin date" - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) - - -def test_validation_when_is_advanced_query_and_has_end_date_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.end = "end date" - with pytest.raises(SystemExit): - main.write_to(sdk, profile, file_event_namespace) - - -def test_validation_when_is_advanced_query_and_has_exposure_types_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.type = [ExposureTypeOptions.SHARED_TO_DOMAIN] - with pytest.raises(SystemExit): - main.send_to(sdk, profile, file_event_namespace) - - -@pytest.mark.parametrize( - "arg", - [ - "c42_username", - "actor", - "md5", - "sha256", - "source", - "file_name", - "file_path", - "process_owner", - "tab_url", - ], -) -def test_validation_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( - sdk, profile, file_event_namespace, arg -): - file_event_namespace.advanced_query = "some complex json" - setattr(file_event_namespace, arg, ["test_value"]) - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) - - -def test_validation_when_is_advanced_query_and_uses_checkpoint_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.use_checkpoint = "foo" - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) - - -def test_validation_when_is_advanced_query_and_has_include_non_exposure_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.include_non_exposure = True - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) - - -def test_validation_when_is_advanced_query_and_has_saved_search_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.saved_search = "abc" - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) - - -def test_validation_when_is_saved_search_and_has_begin_date_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.saved_search = "abc" - file_event_namespace.begin = "begin date" - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) - - -def test_validation_when_is_saved_search_and_has_end_date_exits(sdk, profile, file_event_namespace): - file_event_namespace.saved_search = "abc" - file_event_namespace.end = "end date" - with pytest.raises(SystemExit): - main.write_to(sdk, profile, file_event_namespace) - - -def test_validation_when_is_saved_search_and_has_exposure_types_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.saved_search = "abc" - file_event_namespace.type = [ExposureTypeOptions.SHARED_TO_DOMAIN] - with pytest.raises(SystemExit): - main.send_to(sdk, profile, file_event_namespace) - - -def test_validation_when_is_saved_search_and_uses_checkpoint_mode_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.saved_search = "abc" - file_event_namespace.use_checkpoint = "foo" - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) - - -def test_validation_when_is_saved_search_and_has_include_non_exposure_exits( - sdk, profile, file_event_namespace -): - file_event_namespace.saved_search = "abc" - file_event_namespace.include_non_exposure = True - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) - - -@pytest.mark.parametrize( - "arg", - [ - "c42_username", - "actor", - "md5", - "sha256", - "source", - "file_name", - "file_path", - "process_owner", - "tab_url", - ], -) -def test_validation_when_is_saved_search_and_other_incompatible_multi_narg_argument_passed( - sdk, profile, file_event_namespace, arg -): - file_event_namespace.saved_search = "abc" - setattr(file_event_namespace, arg, ["test_value"]) - with pytest.raises(SystemExit): - main.print_out(sdk, profile, file_event_namespace) diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py new file mode 100644 index 000000000..2857ab803 --- /dev/null +++ b/tests/cmds/test_alert_rules.py @@ -0,0 +1,238 @@ +import logging + +import pytest +from py42.exceptions import Py42InternalServerError +from requests import Request, Response, HTTPError + +from code42cli.main import cli + +TEST_RULE_ID = "rule-id" +TEST_USER_ID = "test-user-id" +TEST_USERNAME = "test@code42.com" + +TEST_EMPTY_RULE_RESPONSE = {"ruleMetadata": []} + +TEST_SYSTEM_RULE_RESPONSE = { + "ruleMetadata": [ + { + u"observerRuleId": TEST_RULE_ID, + "type": u"FED_FILE_TYPE_MISMATCH", + "isSystem": True, + "ruleSource": "NOTVALID", + } + ] +} + +TEST_USER_RULE_RESPONSE = { + "ruleMetadata": [ + { + "observerRuleId": TEST_RULE_ID, + "type": "FED_FILE_TYPE_MISMATCH", + "isSystem": False, + "ruleSource": "Testing", + } + ] +} + +TEST_GET_ALL_RESPONSE_EXFILTRATION = { + "ruleMetadata": [{"observerRuleId": TEST_RULE_ID, "type": "FED_ENDPOINT_EXFILTRATION"}] +} +TEST_GET_ALL_RESPONSE_CLOUD_SHARE = { + "ruleMetadata": [{"observerRuleId": TEST_RULE_ID, "type": "FED_CLOUD_SHARE_PERMISSIONS"}] +} +TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH = { + "ruleMetadata": [{"observerRuleId": TEST_RULE_ID, "type": "FED_FILE_TYPE_MISMATCH"}] +} + + +@pytest.fixture +def get_user_id(mocker): + return mocker.patch("code42cli.cmds.alert_rules.get_user_id") + + +@pytest.fixture +def alert_rules_sdk(sdk): + sdk.alerts.rules.add_user.return_value = {} + sdk.alerts.rules.remove_user.return_value = {} + sdk.alerts.rules.remove_all_users.return_value = {} + sdk.alerts.rules.get_all.return_value = {} + sdk.alerts.rules.exfiltration.get.return_value = {} + sdk.alerts.rules.cloudshare.get.return_value = {} + sdk.alerts.rules.filetypemismatch.get.return_value = {} + return sdk + + +@pytest.fixture +def mock_server_error(mocker): + base_err = HTTPError() + mock_response = mocker.MagicMock(spec=Response) + base_err.response = mock_response + request = mocker.MagicMock(spec=Request) + request.body = '{"test":"body"}' + base_err.response.request = request + + return Py42InternalServerError(base_err) + + +def test_add_user_adds_user_list_to_alert_rules(runner, cli_state): + cli_state.sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_USER_ID}]} + result = runner.invoke( + cli, + ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + cli_state.sdk.alerts.rules.add_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) + + +def test_add_user_when_non_existent_alert_prints_no_rules_message(runner, cli_state): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + result = runner.invoke( + cli, + ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + msg = "No alert rules with RuleId {} found".format(TEST_RULE_ID) + assert msg in result.output + + +def test_add_user_when_returns_500_and_system_rule_exits_with_InvalidRuleTypeError( + runner, cli_state, mock_server_error +): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_SYSTEM_RULE_RESPONSE + cli_state.sdk.alerts.rules.add_user.side_effect = mock_server_error + result = runner.invoke( + cli, + ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + assert result.exit_code == 1 + assert ( + "Only alert rules with a source of 'Alerting' can be targeted by this command." + in result.output + ) + + +def test_add_user_when_returns_500_and_not_system_rule_raises_Py42InternalServerError( + runner, cli_state, mock_server_error, caplog +): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_USER_RULE_RESPONSE + cli_state.sdk.alerts.rules.add_user.side_effect = mock_server_error + with caplog.at_level(logging.ERROR): + result = runner.invoke( + cli, + ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + assert result.exit_code == 1 + assert "Py42InternalServerError" in caplog.text + + +def test_remove_user_removes_user_list_from_alert_rules(runner, cli_state): + cli_state.sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_USER_ID}]} + result = runner.invoke( + cli, + ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + cli_state.sdk.alerts.rules.remove_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) + + +def test_remove_user_when_non_existent_alert_prints_no_rules_message(runner, cli_state): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + result = runner.invoke( + cli, + ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + msg = "No alert rules with RuleId {} found".format(TEST_RULE_ID) + assert msg in result.output + + +def test_remove_user_when_returns_500_and_system_rule_raises_InvalidRuleTypeError( + runner, cli_state, mock_server_error +): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_SYSTEM_RULE_RESPONSE + cli_state.sdk.alerts.rules.remove_user.side_effect = mock_server_error + result = runner.invoke( + cli, + ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + assert result.exit_code == 1 + assert ( + "Only alert rules with a source of 'Alerting' can be targeted by this command." + in result.output + ) + + +def test_remove_user_when_returns_500_and_not_system_rule_raises_Py42InternalServerError( + runner, cli_state, mock_server_error, caplog +): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_USER_RULE_RESPONSE + cli_state.sdk.alerts.rules.remove_user.side_effect = mock_server_error + with caplog.at_level(logging.ERROR): + result = runner.invoke( + cli, + ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + assert result.exit_code == 1 + assert "Py42InternalServerError" in caplog.text + + +def test_list_gets_alert_rules(runner, cli_state): + result = runner.invoke(cli, ["alert-rules", "list"], obj=cli_state) + assert cli_state.sdk.alerts.rules.get_all.call_count == 1 + + +def test_list_when_no_rules_prints_no_rules_message(runner, cli_state): + cli_state.sdk.alerts.rules.get_all.return_value = [TEST_EMPTY_RULE_RESPONSE] + result = runner.invoke(cli, ["alert-rules", "list"], obj=cli_state) + assert "No alert rules found" in result.output + + +def test_show_rule_calls_correct_rule_property(runner, cli_state): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_GET_ALL_RESPONSE_EXFILTRATION + result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) + cli_state.sdk.alerts.rules.exfiltration.get.assert_called_once_with(TEST_RULE_ID) + + +def test_show_rule_calls_correct_rule_property_cloud_share(runner, cli_state): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_GET_ALL_RESPONSE_CLOUD_SHARE + result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) + cli_state.sdk.alerts.rules.cloudshare.get.assert_called_once_with(TEST_RULE_ID) + + +def test_show_rule_calls_correct_rule_property_file_type_mismatch(runner, cli_state): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH + ) + result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) + cli_state.sdk.alerts.rules.filetypemismatch.get.assert_called_once_with(TEST_RULE_ID) + + +def test_show_rule_when_no_matching_rule_prints_no_rule_message(runner, cli_state): + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) + msg = "No alert rules with RuleId {} found".format(TEST_RULE_ID) + assert msg in result.output + + +def test_add_bulk_users_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch("code42cli.cmds.alert_rules.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_add.csv", "w") as csv: + csv.writelines(["rule_id,username\n", "test,value\n"]) + result = runner.invoke(cli, ["alert-rules", "bulk", "add", "test_add.csv"], obj=cli_state) + assert bulk_processor.call_args[0][1] == [{"rule_id": "test", "username": "value"}] + + +def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch("code42cli.cmds.alert_rules.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines(["rule_id,username\n", "test,value\n"]) + result = runner.invoke( + cli, ["alert-rules", "bulk", "add", "test_remove.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [{"rule_id": "test", "username": "value"}] diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py new file mode 100644 index 000000000..209ef09ff --- /dev/null +++ b/tests/cmds/test_alerts.py @@ -0,0 +1,621 @@ +import pytest +from click.testing import CliRunner + +from c42eventextractor.extractors import AlertExtractor +from py42.sdk.queries.alerts.filters import * + +from code42cli import PRODUCT_NAME +from code42cli.main import cli +from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.cmds.search import extraction + +from tests.cmds.conftest import get_filter_value_from_json, filter_term_is_in_call_args +from tests.conftest import get_test_date_str + + +BEGIN_TIMESTAMP = 1577858400.0 +END_TIMESTAMP = 1580450400.0 +CURSOR_TIMESTAMP = 1579500000.0 + + +ALERT_SUMMARY_LIST = [{"id": i} for i in range(20)] + +ALERT_DETAIL_RESULT = [ + {"alerts": [{"id": 1, "createdAt": "2020-01-17"}, {"id": 11, "createdAt": "2020-01-18"}]}, + {"alerts": [{"id": 2, "createdAt": "2020-01-19"}, {"id": 12, "createdAt": "2020-01-20"}]}, + {"alerts": [{"id": 3, "createdAt": "2020-01-01"}, {"id": 13, "createdAt": "2020-01-02"}]}, + {"alerts": [{"id": 4, "createdAt": "2020-01-03"}, {"id": 14, "createdAt": "2020-01-04"}]}, + {"alerts": [{"id": 5, "createdAt": "2020-01-05"}, {"id": 15, "createdAt": "2020-01-06"}]}, + {"alerts": [{"id": 6, "createdAt": "2020-01-07"}, {"id": 16, "createdAt": "2020-01-08"}]}, + {"alerts": [{"id": 7, "createdAt": "2020-01-09"}, {"id": 17, "createdAt": "2020-01-10"}]}, + {"alerts": [{"id": 8, "createdAt": "2020-01-11"}, {"id": 18, "createdAt": "2020-01-12"}]}, + {"alerts": [{"id": 9, "createdAt": "2020-01-13"}, {"id": 19, "createdAt": "2020-01-14"}]}, + {"alerts": [{"id": 10, "createdAt": "2020-01-15"}, {"id": 20, "createdAt": "2020-01-16"}]}, +] + +SORTED_ALERT_DETAILS = [ + {"id": 12, "createdAt": "2020-01-20"}, + {"id": 2, "createdAt": "2020-01-19"}, + {"id": 11, "createdAt": "2020-01-18"}, + {"id": 1, "createdAt": "2020-01-17"}, + {"id": 20, "createdAt": "2020-01-16"}, + {"id": 10, "createdAt": "2020-01-15"}, + {"id": 19, "createdAt": "2020-01-14"}, + {"id": 9, "createdAt": "2020-01-13"}, + {"id": 18, "createdAt": "2020-01-12"}, + {"id": 8, "createdAt": "2020-01-11"}, + {"id": 17, "createdAt": "2020-01-10"}, + {"id": 7, "createdAt": "2020-01-09"}, + {"id": 16, "createdAt": "2020-01-08"}, + {"id": 6, "createdAt": "2020-01-07"}, + {"id": 15, "createdAt": "2020-01-06"}, + {"id": 5, "createdAt": "2020-01-05"}, + {"id": 14, "createdAt": "2020-01-04"}, + {"id": 4, "createdAt": "2020-01-03"}, + {"id": 13, "createdAt": "2020-01-02"}, + {"id": 3, "createdAt": "2020-01-01"}, +] + + +@pytest.fixture +def alert_extractor(mocker): + mock = mocker.patch("{}.cmds.alerts._get_alert_extractor".format(PRODUCT_NAME)) + mock.return_value = mocker.MagicMock(spec=AlertExtractor) + return mock.return_value + + +@pytest.fixture +def alert_cursor_with_checkpoint(mocker): + mock = mocker.patch("{}.cmds.alerts._get_alert_cursor_store".format(PRODUCT_NAME)) + mock_cursor = mocker.MagicMock(spec=AlertCursorStore) + mock_cursor.get.return_value = CURSOR_TIMESTAMP + mock.return_value = mock_cursor + return mock + + +@pytest.fixture +def alert_cursor_without_checkpoint(mocker): + mock = mocker.patch("{}.cmds.alerts._get_alert_cursor_store".format(PRODUCT_NAME)) + mock_cursor = mocker.MagicMock(spec=AlertCursorStore) + mock_cursor.get.return_value = None + mock.return_value = mock_cursor + return mock + + +@pytest.fixture +def begin_option(mocker): + mock = mocker.patch("{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME)) + mock.return_value = BEGIN_TIMESTAMP + return mock + + +@pytest.fixture +def alert_extract_func(mocker): + return mocker.patch("{}.cmds.alerts._extract".format(PRODUCT_NAME)) + + +ADVANCED_QUERY_JSON = '{"some": "complex json"}' + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_is_advanced_query_uses_only_the_extract_advanced_method( + cmd, cli_state, alert_extractor +): + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + ) + alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') + assert alert_extractor.extract.call_count == 0 + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_not_advanced_query_uses_only_the_extract_method(cmd, cli_state, alert_extractor): + runner = CliRunner() + result = runner.invoke(cli, ["alerts", *cmd, "--begin", "1d"], obj=cli_state) + assert alert_extractor.extract.call_count == 1 + assert alert_extractor.extract_advanced.call_count == 0 + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_is_advanced_query_and_has_begin_date_exits(cmd, cli_state): + runner = CliRunner() + result = runner.invoke( + cli, + ["alerts", *cmd, "--advanced-query", ADVANCED_QUERY_JSON, "--begin", "1d"], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "--begin can't be used with: --advanced-query" in result.output + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_advanced_query_and_has_begin_date_exits(cmd, cli_state): + runner = CliRunner() + result = runner.invoke( + cli, + ["alerts", *cmd, "--advanced-query", ADVANCED_QUERY_JSON, "--end", "1d"], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "--end can't be used with: --advanced-query" in result.output + + +@pytest.mark.parametrize( + "arg", + [ + ("--severity", "HIGH"), + ("--actor", "test"), + ("--actor-contains", "test"), + ("--exclude-actor", "test"), + ("--exclude-actor-contains", "test"), + ("--rule-name", "test"), + ("--exclude-rule-name", "test"), + ("--rule-id", "test"), + ("--exclude-rule-id", "test"), + ("--rule-type", "FedEndpointExfiltration"), + ("--exclude-rule-type", "FedEndpointExfiltration"), + ("--description", "test"), + ("--state", "OPEN"), + ("--use-checkpoint", "test"), + ], +) +def test_print_when_advanced_query_and_other_incompatible_argument_passed(arg, cli_state): + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", "print", "--advanced-query", ADVANCED_QUERY_JSON, *arg], obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +@pytest.mark.parametrize( + "arg", + [ + ("--severity", "HIGH"), + ("--actor", "test"), + ("--actor-contains", "test"), + ("--exclude-actor", "test"), + ("--exclude-actor-contains", "test"), + ("--rule-name", "test"), + ("--exclude-rule-name", "test"), + ("--rule-id", "test"), + ("--exclude-rule-id", "test"), + ("--rule-type", "FedEndpointExfiltration"), + ("--exclude-rule-type", "FedEndpointExfiltration"), + ("--description", "test"), + ("--state", "OPEN"), + ("--use-checkpoint", "test"), + ], +) +def test_write_to_when_advanced_query_and_other_incompatible_argument_passed(arg, cli_state): + runner = CliRunner() + result = runner.invoke( + cli, + ["alerts", "write-to", "test_file", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +@pytest.mark.parametrize( + "arg", + [ + ("--severity", "HIGH"), + ("--actor", "test"), + ("--actor-contains", "test"), + ("--exclude-actor", "test"), + ("--exclude-actor-contains", "test"), + ("--rule-name", "test"), + ("--exclude-rule-name", "test"), + ("--rule-id", "test"), + ("--exclude-rule-id", "test"), + ("--rule-type", "FedEndpointExfiltration"), + ("--exclude-rule-type", "FedEndpointExfiltration"), + ("--description", "test"), + ("--state", "OPEN"), + ("--use-checkpoint", "test"), + ], +) +def test_send_to_when_advanced_query_and_other_incompatible_argument_passed(arg, cli_state): + runner = CliRunner() + result = runner.invoke( + cli, + ["alerts", "send-to", "localhost", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_begin_and_end_dates_uses_expected_query(cmd, cli_state, alert_extractor): + begin_date = get_test_date_str(days_ago=89) + end_date = get_test_date_str(days_ago=1) + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", "print", "--begin", begin_date, "--end", end_date], obj=cli_state + ) + filters = alert_extractor.extract.call_args[0][0] + actual_begin = get_filter_value_from_json(filters, filter_index=0) + expected_begin = "{0}T00:00:00.000Z".format(begin_date) + actual_end = get_filter_value_from_json(filters, filter_index=1) + expected_end = "{0}T23:59:59.999Z".format(end_date) + assert actual_begin == expected_begin + assert actual_end == expected_end + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_begin_and_end_date_and_time_uses_expected_query( + cmd, cli_state, alert_extractor +): + begin_date = get_test_date_str(days_ago=89) + end_date = get_test_date_str(days_ago=1) + time = "15:33:02" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "alerts", + "print", + "--begin", + "{} {}".format(begin_date, time), + "--end", + "{} {}".format(end_date, time), + ], + obj=cli_state, + ) + filters = alert_extractor.extract.call_args[0][0] + actual_begin = get_filter_value_from_json(filters, filter_index=0) + expected_begin = "{0}T{1}.000Z".format(begin_date, time) + actual_end = get_filter_value_from_json(filters, filter_index=1) + expected_end = "{0}T{1}.000Z".format(end_date, time) + assert actual_begin == expected_begin + assert actual_end == expected_end + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_begin_date_and_time_without_seconds_uses_expected_query( + cmd, cli_state, alert_extractor +): + date = get_test_date_str(days_ago=89) + time = "15:33" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", "print", "--begin", "{} {}".format(date, time)], obj=cli_state + ) + actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) + expected = "{0}T{1}:00.000Z".format(date, time) + assert actual == expected + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_end_date_and_time_uses_expected_query(cmd, cli_state, alert_extractor): + begin_date = get_test_date_str(days_ago=10) + end_date = get_test_date_str(days_ago=1) + time = "15:33" + runner = CliRunner() + result = runner.invoke( + cli, + ["alerts", "print", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], + obj=cli_state, + ) + actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) + expected = "{0}T{1}:00.000Z".format(end_date, time) + assert actual == expected + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_begin_date_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( + cmd, cli_state, +): + begin_date = get_test_date_str(days_ago=91) + " 12:51:00" + runner = CliRunner() + result = runner.invoke(cli, ["alerts", *cmd, "--begin", begin_date], obj=cli_state) + assert result.exit_code == 2 + assert "must be within 90 days" in result.output + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + cmd, cli_state, alert_cursor_with_checkpoint, mocker, alert_extractor +): + begin_date = get_test_date_str(days_ago=91) + " 12:51:00" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state + ) + assert not filter_term_is_in_call_args(alert_extractor, DateObserved._term) + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( + cmd, cli_state, alert_extractor +): + begin_date = get_test_date_str(days_ago=1) + runner = CliRunner() + result = runner.invoke(cli, ["alerts", *cmd, "--begin", begin_date], obj=cli_state) + + actual_ts = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) + expected_ts = "{0}T00:00:00.000Z".format(begin_date) + assert actual_ts == expected_ts + assert filter_term_is_in_call_args(alert_extractor, DateObserved._term) + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_end_date_is_before_begin_date_causes_exit(cmd, cli_state): + begin_date = get_test_date_str(days_ago=1) + end_date = get_test_date_str(days_ago=3) + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", begin_date, "--end", end_date], obj=cli_state + ) + assert result.exit_code == 2 + assert "'--begin': cannot be after --end date" in result.output + + +def test_get_alert_details_batches_results_according_to_batch_size(sdk): + extraction._ALERT_DETAIL_BATCH_SIZE = 2 + sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT + results = extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) + assert sdk.alerts.get_details.call_count == 10 + + +def test_get_alert_details_sorts_results_by_date(sdk): + extraction._ALERT_DETAIL_BATCH_SIZE = 2 + sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT + results = extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) + assert results == SORTED_ALERT_DETAILS + + +def test_print_with_only_begin_calls_extract_with_expected_args( + mocker, cli_state, alert_extract_func, stdout_logger, begin_option +): + runner = CliRunner() + result = runner.invoke(cli, ["alerts", "print", "--begin", "1h"], obj=cli_state) + alert_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=None, + checkpoint_name=None, + filter_list=cli_state.search_filters, + begin=BEGIN_TIMESTAMP, + end=None, + advanced_query=None, + output_logger=stdout_logger.return_value, + ) + assert result.exit_code == 0 + + +def test_send_to_with_only_begin_calls_extract_with_expected_args( + mocker, cli_state, alert_extract_func, server_logger, begin_option +): + runner = CliRunner() + result = runner.invoke(cli, ["alerts", "send-to", "localhost", "--begin", "1h"], obj=cli_state) + alert_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=None, + checkpoint_name=None, + filter_list=cli_state.search_filters, + begin=BEGIN_TIMESTAMP, + end=None, + advanced_query=None, + output_logger=server_logger.return_value, + ) + assert result.exit_code == 0 + + +def test_write_to_with_only_begin_calls_extract_with_expected_args( + mocker, cli_state, alert_extract_func, file_logger, begin_option +): + runner = CliRunner() + result = runner.invoke(cli, ["alerts", "write-to", "test_file", "--begin", "1h"], obj=cli_state) + alert_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=None, + checkpoint_name=None, + filter_list=cli_state.search_filters, + begin=BEGIN_TIMESTAMP, + end=None, + advanced_query=None, + output_logger=file_logger.return_value, + ) + assert result.exit_code == 0 + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_expected_error( + cmd, cli_state, alert_cursor_without_checkpoint +): + runner = CliRunner() + result = runner.invoke(cli, ["alerts", *cmd, "--use-checkpoint", "test"], obj=cli_state) + assert result.exit_code == 2 + assert ( + "--begin date is required for --use-checkpoint when no checkpoint exists yet." + in result.output + ) + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( + cmd, + cli_state, + alert_extract_func, + begin_option, + alert_cursor_without_checkpoint, + stdout_logger, + server_logger, + file_logger, + mocker, +): + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + ) + assert result.exit_code == 0 + alert_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=alert_cursor_without_checkpoint.return_value, + checkpoint_name="test", + filter_list=cli_state.search_filters, + begin=BEGIN_TIMESTAMP, + end=None, + advanced_query=None, + output_logger=mocker.ANY, + ) + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_with_use_checkpoint_and_with_begin_and_with_checkpoint_calls_extract_with_begin_date_none( + cmd, + cli_state, + alert_extract_func, + alert_cursor_with_checkpoint, + stdout_logger, + server_logger, + file_logger, + mocker, +): + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + ) + assert result.exit_code == 0 + alert_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=alert_cursor_with_checkpoint.return_value, + checkpoint_name="test", + filter_list=cli_state.search_filters, + begin=None, + end=None, + advanced_query=None, + output_logger=mocker.ANY, + ) + assert "checkpoint of 2020-01-20T06:00:00+00:00 exists" in result.output + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_actor_is_uses_username_filter(cmd, cli_state, alert_extractor): + actor_name = "test.testerson" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--actor", actor_name], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(Actor.is_in([actor_name])) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_exclude_actor_uses_actor_filter(cmd, cli_state, alert_extractor): + actor_name = "test.testerson" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--exclude-actor", actor_name], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(Actor.not_in([actor_name])) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_rule_name_uses_rule_name_filter(cmd, cli_state, alert_extractor): + rule_name = "departing employee" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--rule-name", rule_name], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(RuleName.is_in([rule_name])) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_exclude_rule_name_uses_rule_name_not_filter(cmd, cli_state, alert_extractor): + rule_name = "departing employee" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--exclude-rule-name", rule_name], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(RuleName.not_in([rule_name])) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_rule_type_uses_rule_name_filter(cmd, cli_state, alert_extractor): + rule_type = "FedEndpointExfiltration" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--rule-type", rule_type], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(RuleType.is_in([rule_type])) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_exclude_rule_type_uses_rule_name_not_filter(cmd, cli_state, alert_extractor): + rule_type = "FedEndpointExfiltration" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--exclude-rule-type", rule_type], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(RuleType.not_in([rule_type])) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_rule_id_uses_rule_name_filter(cmd, cli_state, alert_extractor): + rule_id = "departing employee" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--rule-id", rule_id], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(RuleId.is_in([rule_id])) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_exclude_rule_id_uses_rule_name_not_filter(cmd, cli_state, alert_extractor): + rule_id = "departing employee" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--exclude-rule-id", rule_id], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(RuleId.not_in([rule_id])) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_description_uses_description_filter(cmd, cli_state, alert_extractor): + description = "test description" + runner = CliRunner() + result = runner.invoke( + cli, ["alerts", *cmd, "--begin", "1h", "--description", description], obj=cli_state + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(Description.contains(description)) in filter_strings + + +@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) +def test_when_given_multiple_search_args_uses_expected_filters(cmd, cli_state, alert_extractor): + actor = "test.testerson@example.com" + exclude_actor = "flag.flagerson@code42.com" + rule_name = "departing employee" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "alerts", + *cmd, + "--begin", + "1h", + "--actor", + actor, + "--exclude-actor", + exclude_actor, + "--rule-name", + rule_name, + ], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(Actor.is_in([actor])) in filter_strings + assert str(Actor.not_in([exclude_actor])) in filter_strings + assert str(RuleName.is_in([rule_name])) in filter_strings diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py new file mode 100644 index 000000000..382826ae4 --- /dev/null +++ b/tests/cmds/test_departing_employee.py @@ -0,0 +1,141 @@ +from code42cli.main import cli + +from tests.conftest import TEST_ID +from tests.cmds.conftest import thread_safe_side_effect + + +_EMPLOYEE = "departing employee" + + +def test_add_departing_employee_when_given_cloud_alias_adds_alias(runner, cli_state_with_user): + alias = "departing employee alias" + result = runner.invoke( + cli, + ["departing-employee", "add", _EMPLOYEE, "--cloud-alias", alias], + obj=cli_state_with_user, + ) + cli_state_with_user.sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( + TEST_ID, alias + ) + + +def test_add_departing_employee_when_given_notes_updates_notes( + runner, cli_state_with_user, profile +): + notes = "is leaving" + result = runner.invoke( + cli, ["departing-employee", "add", _EMPLOYEE, "--notes", notes], obj=cli_state_with_user, + ) + cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) + + +def test_add_departing_employee_adds( + runner, cli_state_with_user, +): + departure_date = "2020-02-02" + result = runner.invoke( + cli, + ["departing-employee", "add", _EMPLOYEE, "--departure-date", departure_date], + obj=cli_state_with_user, + ) + cli_state_with_user.sdk.detectionlists.departing_employee.add.assert_called_once_with( + TEST_ID, "2020-02-02" + ) + + +def test_add_departing_employee_when_user_does_not_exist_exits(runner, cli_state_without_user): + result = runner.invoke( + cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_without_user + ) + assert result.exit_code == 1 + assert "User '{}' does not exist.".format(_EMPLOYEE) in result.output + + +def test_add_departing_employee_when_user_already_added_raises_UserAlreadyAddedError( + runner, cli_state_with_user, bad_request_for_user_already_added +): + cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = ( + bad_request_for_user_already_added + ) + result = runner.invoke(cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + assert result.exit_code == 1 + assert "'{}' is already on the departing-employee list.".format(_EMPLOYEE) + + +def test_add_departing_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( + runner, cli_state_with_user, generic_bad_request +): + cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = generic_bad_request + result = runner.invoke(cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + assert result.exit_code == 1 + assert "Problem making request to server." in result.output + assert "View details in" in result.output + + +def test_remove_departing_employee_calls_remove(runner, cli_state_with_user): + result = runner.invoke( + cli, ["departing-employee", "remove", _EMPLOYEE], obj=cli_state_with_user + ) + cli_state_with_user.sdk.detectionlists.departing_employee.remove.assert_called_once_with( + TEST_ID + ) + + +def test_remove_departing_employee_when_user_does_not_exist_exits(runner, cli_state_without_user): + result = runner.invoke( + cli, ["departing-employee", "remove", _EMPLOYEE], obj=cli_state_without_user + ) + assert result.exit_code == 1 + assert "User '{}' does not exist.".format(_EMPLOYEE) in result.output + + +def test_add_bulk_users_calls_expected_py42_methods(runner, mocker, cli_state): + de_add_user = thread_safe_side_effect() + add_user_cloud_alias = thread_safe_side_effect() + update_user_notes = thread_safe_side_effect() + + cli_state.sdk.detectionlists.departing_employee.add.side_effect = de_add_user + cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = add_user_cloud_alias + cli_state.sdk.detectionlists.update_user_notes.side_effect = update_user_notes + + with runner.isolated_filesystem(): + with open("test_add.csv", "w") as csv: + csv.writelines( + [ + "username,cloud_alias,departure_date,notes\n", + "test_user,test_alias,2020-01-01,test_note\n", + "test_user_2,test_alias_2,2020-02-01,test_note_2\n", + "test_user_3,,,\n", + ] + ) + result = runner.invoke( + cli, ["departing-employee", "bulk", "add", "test_add.csv"], obj=cli_state + ) + de_add_user_call_args = [call[1] for call in de_add_user.call_args_list] + assert de_add_user.call_count == 3 + assert "2020-01-01" in de_add_user_call_args + assert "2020-02-01" in de_add_user_call_args + assert None in de_add_user_call_args + + add_user_cloud_alias_call_args = [call[1] for call in add_user_cloud_alias.call_args_list] + assert add_user_cloud_alias.call_count == 2 + assert "test_alias" in add_user_cloud_alias_call_args + assert "test_alias_2" in add_user_cloud_alias_call_args + + update_user_notes_call_args = [call[1] for call in update_user_notes.call_args_list] + assert update_user_notes.call_count == 2 + assert "test_note" in update_user_notes_call_args + assert "test_note_2" in update_user_notes_call_args + + +def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state_with_user): + bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines(["# username\n", "test_user1\n", "test_user2\n"]) + result = runner.invoke( + cli, + ["departing-employee", "bulk", "remove", "test_remove.csv"], + obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == ["test_user1", "test_user2"] diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py new file mode 100644 index 000000000..f0df03850 --- /dev/null +++ b/tests/cmds/test_high_risk_employee.py @@ -0,0 +1,198 @@ +from code42cli.main import cli + +from tests.conftest import TEST_ID +from tests.cmds.conftest import thread_safe_side_effect + +_NAMESPACE = "code42cli.cmds.high_risk_employee" +_EMPLOYEE = "risky employee" + + +def test_add_high_risk_employee_adds(runner, cli_state_with_user): + result = runner.invoke(cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + cli_state_with_user.sdk.detectionlists.high_risk_employee.add.assert_called_once_with(TEST_ID) + + +def test_add_high_risk_employee_when_given_cloud_alias_adds_alias(runner, cli_state_with_user): + alias = "risk employee alias" + result = runner.invoke( + cli, + ["high-risk-employee", "add", _EMPLOYEE, "--cloud-alias", alias], + obj=cli_state_with_user, + ) + cli_state_with_user.sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( + TEST_ID, alias + ) + + +def test_add_high_risk_employee_when_given_risk_tags_adds_tags(runner, cli_state_with_user): + result = runner.invoke( + cli, + [ + "high-risk-employee", + "add", + _EMPLOYEE, + "-t", + "FLIGHT_RISK", + "-t", + "ELEVATED_ACCESS_PRIVILEGES", + "-t", + "POOR_SECURITY_PRACTICES", + ], + obj=cli_state_with_user, + ) + cli_state_with_user.sdk.detectionlists.add_user_risk_tags.assert_called_once_with( + TEST_ID, ("FLIGHT_RISK", "ELEVATED_ACCESS_PRIVILEGES", "POOR_SECURITY_PRACTICES") + ) + + +def test_add_high_risk_employee_when_given_notes_updates_notes(runner, cli_state_with_user): + notes = "being risky" + result = runner.invoke( + cli, ["high-risk-employee", "add", _EMPLOYEE, "--notes", notes], obj=cli_state_with_user, + ) + cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) + + +def test_add_high_risk_employee_when_user_does_not_exist_exits_with_correct_message( + runner, cli_state_without_user +): + result = runner.invoke( + cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_without_user + ) + assert result.exit_code == 1 + assert "User '{}' does not exist.".format(_EMPLOYEE) in result.output + + +def test_add_high_risk_employee_when_user_already_added_exits_with_correct_message( + runner, cli_state_with_user, bad_request_for_user_already_added +): + cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = ( + bad_request_for_user_already_added + ) + result = runner.invoke(cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + assert result.exit_code == 1 + assert "'{}' is already on the high-risk-employee list.".format(_EMPLOYEE) in result.output + + +def test_add_high_risk_employee_when_bad_request_but_not_user_already_added_exits_with_message_to_see_logs( + runner, cli_state_with_user, generic_bad_request +): + cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = generic_bad_request + result = runner.invoke(cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + assert result.exit_code == 1 + assert "Problem making request to server." in result.output + assert "View details in" in result.output + + +def test_remove_high_risk_employee_calls_remove(runner, cli_state_with_user): + result = runner.invoke( + cli, ["high-risk-employee", "remove", _EMPLOYEE], obj=cli_state_with_user + ) + cli_state_with_user.sdk.detectionlists.high_risk_employee.remove.assert_called_once_with( + TEST_ID + ) + + +def test_remove_high_risk_employee_when_user_does_not_exist_exits_with_correct_message( + runner, cli_state_without_user +): + result = runner.invoke( + cli, ["high-risk-employee", "remove", _EMPLOYEE], obj=cli_state_without_user + ) + assert result.exit_code == 1 + assert "User '{}' does not exist.".format(_EMPLOYEE) in result.output + + +def test_generate_template_file_when_given_add_generates_template_from_handler( + runner, mocker, cli_state +): + pass + + +def test_generate_template_file_when_given_remove_generates_template_from_handler(): + pass + + +def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state, mocker): + add_user_cloud_alias = thread_safe_side_effect() + add_user_risk_tags = thread_safe_side_effect() + update_user_notes = thread_safe_side_effect() + hre_add_user = thread_safe_side_effect() + + cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = add_user_cloud_alias + cli_state.sdk.detectionlists.add_user_risk_tags.side_effect = add_user_risk_tags + cli_state.sdk.detectionlists.update_user_notes.side_effect = update_user_notes + cli_state.sdk.detectionlists.high_risk_employee.add.side_effect = hre_add_user + + with runner.isolated_filesystem(): + with open("test_add.csv", "w") as csv: + csv.writelines( + [ + "username,cloud_alias,risk_tag,notes\n", + "test_user,test_alias,test_tag_1 test_tag_2,test_note\n", + "test_user_2,test_alias_2,test_tag_3,test_note_2\n", + "test_user_3,,,\n", + ] + ) + result = runner.invoke( + cli, ["high-risk-employee", "bulk", "add", "test_add.csv"], obj=cli_state + ) + alias_args = [call[1] for call in add_user_cloud_alias.call_args_list] + assert add_user_cloud_alias.call_count == 2 + assert "test_alias" in alias_args + assert "test_alias_2" in alias_args + + add_risk_tags_call_args = [call[1] for call in add_user_risk_tags.call_args_list] + assert add_user_risk_tags.call_count == 2 + assert ["test_tag_1", "test_tag_2"] in add_risk_tags_call_args + assert ["test_tag_3"] in add_risk_tags_call_args + + add_notes_call_args = [call[1] for call in update_user_notes.call_args_list] + assert update_user_notes.call_count == 2 + assert "test_note" in add_notes_call_args + assert "test_note_2" in add_notes_call_args + + assert hre_add_user.call_count == 3 + + +def test_bulk_remove_employees_uses_expected_arguments(runner, cli_state, mocker): + bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines(["# username\n", "test@example.com\n", "test2@example.com"]) + result = runner.invoke( + cli, ["high-risk-employee", "bulk", "remove", "test_remove.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == ["test@example.com", "test2@example.com"] + + +def test_bulk_add_risk_tags_uses_expected_arguments(runner, cli_state, mocker): + bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + with runner.isolated_filesystem(): + with open("test_add_risk_tags.csv", "w") as csv: + csv.writelines(["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"]) + result = runner.invoke( + cli, + ["high-risk-employee", "bulk", "add-risk-tags", "test_add_risk_tags.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": "test@example.com", "tag": "tag1"}, + {"username": "test2@example.com", "tag": "tag2"}, + ] + + +def test_bulk_remove_risk_tags_uses_expected_arguments(runner, cli_state, mocker): + bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + with runner.isolated_filesystem(): + with open("test_remove_risk_tags.csv", "w") as csv: + csv.writelines(["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"]) + result = runner.invoke( + cli, + ["high-risk-employee", "bulk", "remove-risk-tags", "test_remove_risk_tags.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": "test@example.com", "tag": "tag1"}, + {"username": "test2@example.com", "tag": "tag2"}, + ] diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py new file mode 100644 index 000000000..3af3af3ba --- /dev/null +++ b/tests/cmds/test_legal_hold.py @@ -0,0 +1,351 @@ +import pytest +from requests import Response, HTTPError + +from code42cli import PRODUCT_NAME +from code42cli.cmds.legal_hold import _check_matter_is_accessible +from code42cli.main import cli + +_NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) + +from py42.exceptions import Py42BadRequestError +from py42.response import Py42Response + + +TEST_MATTER_ID = "99999" +TEST_LEGAL_HOLD_MEMBERSHIP_UID = "88888" +TEST_LEGAL_HOLD_MEMBERSHIP_UID_2 = "77777" +ACTIVE_TEST_USERNAME = "user@example.com" +ACTIVE_TEST_USER_ID = "12345" +INACTIVE_TEST_USERNAME = "inactive@example.com" +INACTIVE_TEST_USER_ID = "54321" + +TEST_POLICY_UID = "66666" + +TEST_MATTER_RESULT = { + "legalHoldUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID, + "name": "Test_Matter", + "description": "", + "active": True, + "creationDate": "2020-01-01T00:00:00.000-06:00", + "creator": {"userUid": "942564422882759874", "username": "legal_admin@example.com"}, + "holdPolicyUid": TEST_POLICY_UID, +} + +ACTIVE_LEGAL_HOLD_MEMBERSHIP = { + "legalHoldMembershipUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID, + "user": {"userUid": ACTIVE_TEST_USER_ID, "username": ACTIVE_TEST_USERNAME}, + "active": True, +} +INACTIVE_LEGAL_HOLD_MEMBERSHIP = { + "legalHoldMembershipUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID_2, + "user": {"userUid": INACTIVE_TEST_USER_ID, "username": INACTIVE_TEST_USERNAME}, + "active": False, +} + + +EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": []}] +ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP]}] +ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ + {"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP, INACTIVE_LEGAL_HOLD_MEMBERSHIP]} +] +INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ + {"legalHoldMemberships": [INACTIVE_LEGAL_HOLD_MEMBERSHIP]} +] + +TEST_PRESERVATION_POLICY_UID = "1010101010" +TEST_PRESERVATION_POLICY_JSON = '{{"creationDate": "2020-01-01","legalHoldPolicyUid": {}}}'.format( + TEST_PRESERVATION_POLICY_UID +) + + +@pytest.fixture +def preservation_policy_response(mocker): + response = mocker.MagicMock(spec=Response) + response.text = TEST_PRESERVATION_POLICY_JSON + return Py42Response(response) + + +@pytest.fixture +def get_user_id_success(cli_state): + cli_state.sdk.users.get_by_username.return_value = {"users": [{"userUid": ACTIVE_TEST_USER_ID}]} + + +@pytest.fixture +def get_user_id_failure(cli_state): + cli_state.sdk.users.get_by_username.return_value = {"users": []} + + +@pytest.fixture +def check_matter_accessible_success(cli_state): + cli_state.sdk.legalhold.get_matter_by_uid.return_value = TEST_MATTER_RESULT + + +@pytest.fixture +def check_matter_accessible_failure(cli_state): + cli_state.sdk.legalhold.get_matter_by_uid.side_effect = Py42BadRequestError(HTTPError()) + + +@pytest.fixture +def user_already_added_response(mocker): + mock_response = mocker.MagicMock(spec=Response) + mock_response.text = "USER_ALREADY_IN_HOLD" + http_error = HTTPError() + http_error.response = mock_response + return Py42BadRequestError(http_error) + + +def test_add_user_raises_user_already_added_error_when_user_already_on_hold( + runner, cli_state, user_already_added_response +): + + cli_state.sdk.legalhold.add_to_matter.side_effect = user_already_added_response + result = runner.invoke( + cli, + [ + "legal-hold", + "add-user", + "--matter-id", + TEST_MATTER_ID, + "--username", + ACTIVE_TEST_USERNAME, + ], + obj=cli_state, + ) + assert result.exit_code == 1 + assert "'{0}' is already on the legal hold matter id={1}".format( + ACTIVE_TEST_USERNAME, TEST_MATTER_ID + ) + + +def test_add_user_raises_legalhold_not_found_error_if_matter_inaccessible( + runner, cli_state, check_matter_accessible_failure, get_user_id_success +): + result = runner.invoke( + cli, + [ + "legal-hold", + "add-user", + "--matter-id", + TEST_MATTER_ID, + "--username", + ACTIVE_TEST_USERNAME, + ], + obj=cli_state, + ) + assert result.exit_code == 1 + assert "Matter with id={0} either does not exist or your profile does not have permission to view it.".format( + TEST_MATTER_ID + ) + + +def test_add_user_adds_user_to_hold_if_user_and_matter_exist( + runner, cli_state, check_matter_accessible_success, get_user_id_success +): + result = runner.invoke( + cli, + [ + "legal-hold", + "add-user", + "--matter-id", + TEST_MATTER_ID, + "--username", + ACTIVE_TEST_USERNAME, + ], + obj=cli_state, + ) + cli_state.sdk.legalhold.add_to_matter.assert_called_once_with( + ACTIVE_TEST_USER_ID, TEST_MATTER_ID + ) + + +def test_remove_user_raises_legalhold_not_found_error_if_matter_inaccessible( + runner, cli_state, check_matter_accessible_failure, get_user_id_success +): + result = runner.invoke( + cli, + [ + "legal-hold", + "remove-user", + "--matter-id", + TEST_MATTER_ID, + "--username", + ACTIVE_TEST_USERNAME, + ], + obj=cli_state, + ) + assert result.exit_code == 1 + assert "Matter with id={0} either does not exist or your profile does not have " + "permission to view it.".format(TEST_MATTER_ID) + + +def test_remove_user_raises_user_not_in_matter_error_if_user_not_active_in_matter( + runner, cli_state, check_matter_accessible_success, get_user_id_success +): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + result = runner.invoke( + cli, + [ + "legal-hold", + "remove-user", + "--matter-id", + TEST_MATTER_ID, + "--username", + ACTIVE_TEST_USERNAME, + ], + obj=cli_state, + ) + assert result.exit_code == 1 + assert "User '{0}' is not an active member of legal hold matter '{1}'".format( + ACTIVE_TEST_USERNAME, TEST_MATTER_ID + ) + + +def test_remove_user_removes_user_if_user_in_matter( + runner, cli_state, check_matter_accessible_success, get_user_id_success +): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + + membership_uid = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT[0]["legalHoldMemberships"][0][ + "legalHoldMembershipUid" + ] + result = runner.invoke( + cli, + [ + "legal-hold", + "remove-user", + "--matter-id", + TEST_MATTER_ID, + "--username", + ACTIVE_TEST_USERNAME, + ], + obj=cli_state, + ) + cli_state.sdk.legalhold.remove_from_matter.assert_called_with(membership_uid) + + +def test_matter_accessible_check_only_makes_one_http_call_when_called_multiple_times_with_same_matter_id( + sdk, check_matter_accessible_success +): + _check_matter_is_accessible(sdk, TEST_MATTER_ID) + _check_matter_is_accessible(sdk, TEST_MATTER_ID) + _check_matter_is_accessible(sdk, TEST_MATTER_ID) + _check_matter_is_accessible(sdk, TEST_MATTER_ID) + assert sdk.legalhold.get_matter_by_uid.call_count == 1 + + +def test_show_matter_prints_active_and_inactive_results_when_include_inactive_flag_set( + runner, cli_state, check_matter_accessible_success +): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + result = runner.invoke( + cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-inactive"], obj=cli_state + ) + assert ACTIVE_TEST_USERNAME in result.output + assert INACTIVE_TEST_USERNAME in result.output + + +def test_show_matter_prints_active_results_only(runner, cli_state, check_matter_accessible_success): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + result = runner.invoke(cli, ["legal-hold", "show", TEST_MATTER_ID], obj=cli_state) + assert ACTIVE_TEST_USERNAME in result.output + assert INACTIVE_TEST_USERNAME not in result.output + + +def test_show_matter_prints_no_active_members_when_no_membership( + runner, cli_state, check_matter_accessible_success +): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + result = runner.invoke(cli, ["legal-hold", "show", TEST_MATTER_ID], obj=cli_state) + assert ACTIVE_TEST_USERNAME not in result.output + assert INACTIVE_TEST_USERNAME not in result.output + assert "No active matter members." in result.output + + +def test_show_matter_prints_no_inactive_members_when_no_inactive_membership( + runner, cli_state, check_matter_accessible_success +): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + result = runner.invoke( + cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-inactive"], obj=cli_state + ) + assert ACTIVE_TEST_USERNAME in result.output + assert INACTIVE_TEST_USERNAME not in result.output + assert "No inactive matter members." in result.output + + +def test_show_matter_prints_no_active_members_when_no_active_membership( + runner, cli_state, check_matter_accessible_success +): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + result = runner.invoke( + cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-inactive"], obj=cli_state + ) + assert ACTIVE_TEST_USERNAME not in result.output + assert INACTIVE_TEST_USERNAME in result.output + assert "No active matter members." in result.output + + +def test_show_matter_prints_no_active_members_when_no_active_membership_and_inactive_membership_included( + runner, cli_state, check_matter_accessible_success +): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + result = runner.invoke( + cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-inactive"], obj=cli_state + ) + assert ACTIVE_TEST_USERNAME not in result.output + assert INACTIVE_TEST_USERNAME in result.output + assert "No active matter members." in result.output + + +def test_show_matter_prints_preservation_policy_when_include_policy_flag_set( + runner, cli_state, check_matter_accessible_success, preservation_policy_response +): + cli_state.sdk.legalhold.get_policy_by_uid.return_value = preservation_policy_response + result = runner.invoke( + cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-policy"], obj=cli_state + ) + assert TEST_PRESERVATION_POLICY_UID in result.output + + +def test_show_matter_does_not_print_preservation_policy( + runner, cli_state, check_matter_accessible_success, preservation_policy_response +): + cli_state.sdk.legalhold.get_policy_by_uid.return_value = preservation_policy_response + result = runner.invoke(cli, ["legal-hold", "show", TEST_MATTER_ID], obj=cli_state) + assert TEST_PRESERVATION_POLICY_UID not in result.output + + +def test_add_bulk_users_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + with runner.isolated_filesystem(): + with open("test_add.csv", "w") as csv: + csv.writelines(["matter_id,username\n", "test,value\n"]) + result = runner.invoke(cli, ["legal-hold", "bulk", "add", "test_add.csv"], obj=cli_state) + assert bulk_processor.call_args[0][1] == [{"matter_id": "test", "username": "value"}] + + +def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines(["matter_id,username\n", "test,value\n"]) + result = runner.invoke( + cli, ["legal-hold", "bulk", "remove", "test_remove.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [{"matter_id": "test", "username": "value"}] diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index d291d5b4d..f376f6ea6 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,8 +1,8 @@ import pytest -import logging -import code42cli.cmds.profile as profilecmd from code42cli import PRODUCT_NAME +from code42cli.errors import Code42CLIError, LoggedCLIError +from code42cli.main import cli from ..conftest import create_mock_profile @@ -44,231 +44,233 @@ def valid_connection(mock_verify): @pytest.fixture def invalid_connection(mock_verify): - mock_verify.return_value = False + mock_verify.side_effect = LoggedCLIError("Problem connecting to server") return mock_verify -def test_show_profile_outputs_profile_info(caplog, mock_cliprofile_namespace, profile): +def test_show_profile_outputs_profile_info(runner, mock_cliprofile_namespace, profile): profile.name = "testname" profile.authority_url = "example.com" profile.username = "foo" profile.disable_ssl_errors = True mock_cliprofile_namespace.get_profile.return_value = profile - profilecmd.show_profile(profile) - assert "testname" in caplog.text - assert "example.com" in caplog.text - assert "foo" in caplog.text - assert "A password is set" in caplog.text + result = runner.invoke(cli, ["profile", "show"]) + assert "testname" in result.output + assert "example.com" in result.output + assert "foo" in result.output + assert "A password is set" in result.output def test_show_profile_when_password_set_outputs_password_note( - capsys, mock_cliprofile_namespace, profile + runner, mock_cliprofile_namespace, profile ): mock_cliprofile_namespace.get_profile.return_value = profile mock_cliprofile_namespace.get_stored_password.return_value = None - profilecmd.show_profile(profile) - capture = capsys.readouterr() - assert "A password is set" not in capture.out + result = runner.invoke(cli, ["profile", "show"]) + assert "A password is set" not in result.output def test_create_profile_if_user_sets_password_is_created( - user_agreement, mock_verify, mock_cliprofile_namespace + runner, user_agreement, mock_verify, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False - profilecmd.create_profile("foo", "bar", "baz", True) + runner.invoke( + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) mock_cliprofile_namespace.create_profile.assert_called_once_with("foo", "bar", "baz", True) def test_create_profile_if_user_does_not_set_password_is_created( - user_disagreement, mock_verify, mock_cliprofile_namespace + runner, user_disagreement, mock_verify, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False - profilecmd.create_profile("foo", "bar", "baz", True) + runner.invoke( + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) mock_cliprofile_namespace.create_profile.assert_called_once_with("foo", "bar", "baz", True) def test_create_profile_if_user_does_not_agree_does_not_save_password( - user_disagreement, mock_verify, mock_cliprofile_namespace + runner, user_disagreement, mock_verify, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False - profilecmd.create_profile("foo", "bar", "baz", True) + runner.invoke( + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) assert not mock_cliprofile_namespace.set_password.call_count def test_create_profile_if_credentials_invalid_password_not_saved( - user_agreement, invalid_connection, mock_cliprofile_namespace + runner, user_agreement, invalid_connection, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False success = False - try: - profilecmd.create_profile("foo", "bar", "baz", True) - except SystemExit: - success = True - assert not mock_cliprofile_namespace.set_password.call_count - assert success + result = runner.invoke( + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"], + ) + assert "Password not stored!" in result.output + assert not mock_cliprofile_namespace.set_password.call_count def test_create_profile_if_credentials_valid_password_saved( - mocker, user_agreement, valid_connection, mock_cliprofile_namespace + runner, mocker, user_agreement, valid_connection, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False - profilecmd.create_profile("foo", "bar", "baz", True) + runner.invoke( + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) def test_create_profile_outputs_confirmation( - user_agreement, valid_connection, mock_cliprofile_namespace, caplog + runner, user_agreement, valid_connection, mock_cliprofile_namespace ): - with caplog.at_level(logging.INFO): - mock_cliprofile_namespace.profile_exists.return_value = False - profilecmd.create_profile("foo", "bar", "baz", True) - assert "Successfully created profile 'foo'." in caplog.text + mock_cliprofile_namespace.profile_exists.return_value = False + result = runner.invoke( + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) + assert "Successfully created profile 'foo'." in result.output def test_update_profile_updates_existing_profile( - mock_cliprofile_namespace, user_agreement, valid_connection, profile + runner, mock_cliprofile_namespace, user_agreement, valid_connection, profile ): name = "foo" profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile - - profilecmd.update_profile(name=name, server="bar", username="baz", disable_ssl_errors=True) + runner.invoke( + cli, ["profile", "update", "-n", name, "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) mock_cliprofile_namespace.update_profile.assert_called_once_with(name, "bar", "baz", True) def test_update_profile_if_user_does_not_agree_does_not_save_password( - mock_cliprofile_namespace, user_disagreement, invalid_connection, profile + runner, mock_cliprofile_namespace, user_disagreement, invalid_connection, profile ): name = "foo" profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile + runner.invoke( + cli, ["profile", "update", "-n", name, "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) assert not mock_cliprofile_namespace.set_password.call_count def test_update_profile_if_credentials_invalid_password_not_saved( - user_agreement, invalid_connection, mock_cliprofile_namespace, profile + runner, user_agreement, invalid_connection, mock_cliprofile_namespace, profile ): name = "foo" profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile - success = False - try: - profilecmd.create_profile("foo", "bar", "baz", True) - except SystemExit: - success = True - assert not mock_cliprofile_namespace.set_password.call_count - assert success + result = runner.invoke( + cli, ["profile", "update", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) + assert not mock_cliprofile_namespace.set_password.call_count + assert "Password not stored!" in result.output def test_update_profile_if_user_agrees_and_valid_connection_sets_password( - mocker, user_agreement, valid_connection, mock_cliprofile_namespace, profile + runner, mocker, user_agreement, valid_connection, mock_cliprofile_namespace, profile ): name = "foo" profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile - - profilecmd.update_profile(name, "bar", "baz", True) + runner.invoke( + cli, ["profile", "update", "-n", name, "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + ) mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) def test_delete_profile_warns_if_deleting_default( - caplog, user_agreement, mock_cliprofile_namespace + runner, user_agreement, mock_cliprofile_namespace ): mock_cliprofile_namespace.is_default_profile.return_value = True - with caplog.at_level(logging.ERROR): - profilecmd.delete_profile("mockdefault") - assert "mockdefault is currently the default profile!" in caplog.text + result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) + assert "mockdefault is currently the default profile!" in result.output def test_delete_profile_does_nothing_if_user_doesnt_agree( - user_disagreement, mock_cliprofile_namespace + runner, user_disagreement, mock_cliprofile_namespace ): - profilecmd.delete_profile("mockprofile") + result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) assert mock_cliprofile_namespace.delete_profile.call_count == 0 -def test_delete_profile_outputs_success(user_agreement, mock_cliprofile_namespace, caplog): - with caplog.at_level(logging.INFO): - profilecmd.delete_profile("mockprofile") - assert "Profile 'mockProfile' has been deleted." +def test_delete_profile_outputs_success(runner, mock_cliprofile_namespace, user_agreement): + result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) + assert "Profile 'mockdefault' has been deleted." in result.output -def test_delete_all_warns_if_profiles_exist(caplog, user_agreement, mock_cliprofile_namespace): +def test_delete_all_warns_if_profiles_exist(runner, user_agreement, mock_cliprofile_namespace): mock_cliprofile_namespace.get_all_profiles.return_value = [ create_mock_profile("test1"), create_mock_profile("test2"), ] - with caplog.at_level(logging.INFO): - profilecmd.delete_all_profiles() - assert "Are you sure you want to delete the following profiles?" in caplog.text - assert "test1" in caplog.text - assert "test2" in caplog.text + result = runner.invoke(cli, ["profile", "delete-all"]) + assert "Are you sure you want to delete the following profiles?" in result.output + assert "test1" in result.output + assert "test2" in result.output def test_delete_all_profiles_does_nothing_if_user_doesnt_agree( - user_disagreement, mock_cliprofile_namespace + runner, user_disagreement, mock_cliprofile_namespace ): - profilecmd.delete_all_profiles() + result = runner.invoke(cli, ["profile", "delete-all"]) assert mock_cliprofile_namespace.delete_profile.call_count == 0 -def test_delete_all_deletes_all_existing_profiles(user_agreement, mock_cliprofile_namespace): +def test_delete_all_deletes_all_existing_profiles( + runner, user_agreement, mock_cliprofile_namespace +): mock_cliprofile_namespace.get_all_profiles.return_value = [ create_mock_profile("test1"), create_mock_profile("test2"), ] - profilecmd.delete_all_profiles() + result = runner.invoke(cli, ["profile", "delete-all"]) mock_cliprofile_namespace.delete_profile.assert_any_call("test1") mock_cliprofile_namespace.delete_profile.assert_any_call("test2") def test_prompt_for_password_reset_if_credentials_valid_password_saved( - mocker, user_agreement, mock_verify, mock_cliprofile_namespace + runner, mocker, user_agreement, mock_verify, mock_cliprofile_namespace ): mock_verify.return_value = True mock_cliprofile_namespace.profile_exists.return_value = False - profilecmd.prompt_for_password_reset() + result = runner.invoke(cli, ["profile", "reset-pw"]) mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) def test_prompt_for_password_reset_if_credentials_invalid_password_not_saved( - user_agreement, mock_verify, mock_cliprofile_namespace + runner, user_agreement, mock_verify, mock_cliprofile_namespace ): - mock_verify.return_value = False + mock_verify.side_effect = Code42CLIError("Invalid credentials for user") mock_cliprofile_namespace.profile_exists.return_value = False - success = False - try: - profilecmd.prompt_for_password_reset() - except SystemExit: - success = True - assert not mock_cliprofile_namespace.set_password.call_count - assert success + result = runner.invoke(cli, ["profile", "reset-pw"]) + assert not mock_cliprofile_namespace.set_password.call_count -def test_list_profiles(caplog, mock_cliprofile_namespace): +def test_list_profiles(runner, mock_cliprofile_namespace): profiles = [ create_mock_profile("one"), create_mock_profile("two"), create_mock_profile("three"), ] mock_cliprofile_namespace.get_all_profiles.return_value = profiles - with caplog.at_level(logging.INFO): - profilecmd.list_profiles() - assert "one" in caplog.text - assert "two" in caplog.text - assert "three" in caplog.text + result = runner.invoke(cli, ["profile", "list"]) + assert "one" in result.output + assert "two" in result.output + assert "three" in result.output def test_list_profiles_when_no_profiles_outputs_no_profiles_message( - caplog, mock_cliprofile_namespace + runner, mock_cliprofile_namespace ): mock_cliprofile_namespace.get_all_profiles.return_value = [] - profilecmd.list_profiles() - with caplog.at_level(logging.ERROR): - assert "No existing profile." in caplog.text + result = runner.invoke(cli, ["profile", "list"]) + assert "No existing profile." in result.output -def test_use_profile(mock_cliprofile_namespace, profile): - profilecmd.use_profile(profile) - mock_cliprofile_namespace.switch_default_profile.assert_called_once_with(profile) +def test_use_profile(runner, mock_cliprofile_namespace, profile): + result = runner.invoke(cli, ["profile", "use", profile.name]) + mock_cliprofile_namespace.switch_default_profile.assert_called_once_with(profile.name) diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py new file mode 100644 index 000000000..f1621a37d --- /dev/null +++ b/tests/cmds/test_securitydata.py @@ -0,0 +1,684 @@ +import logging + +import pytest +from c42eventextractor.extractors import FileEventExtractor +from py42.sdk.queries.fileevents.file_event_query import FileEventQuery +from py42.sdk.queries.fileevents.filters import * + +from code42cli import PRODUCT_NAME, errors +from code42cli.cmds.search.cursor_store import FileEventCursorStore +from code42cli.main import cli +from tests.cmds.conftest import get_filter_value_from_json, filter_term_is_in_call_args +from tests.conftest import get_test_date_str + +BEGIN_TIMESTAMP = 1577858400.0 +END_TIMESTAMP = 1580450400.0 +CURSOR_TIMESTAMP = 1579500000.0 + + +@pytest.fixture +def file_event_extractor(mocker): + mock = mocker.patch("{}.cmds.securitydata._get_file_event_extractor".format(PRODUCT_NAME)) + mock.return_value = mocker.MagicMock(spec=FileEventExtractor) + return mock.return_value + + +@pytest.fixture +def file_event_cursor_with_checkpoint(mocker): + mock = mocker.patch("{}.cmds.securitydata._get_file_event_cursor_store".format(PRODUCT_NAME)) + mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) + mock_cursor.get.return_value = CURSOR_TIMESTAMP + mock.return_value = mock_cursor + return mock + + +@pytest.fixture +def file_event_cursor_without_checkpoint(mocker): + mock = mocker.patch("{}.cmds.securitydata._get_file_event_cursor_store".format(PRODUCT_NAME)) + mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) + mock_cursor.get.return_value = None + mock.return_value = mock_cursor + return mock + + +@pytest.fixture +def file_event_extract_func(mocker): + return mocker.patch("{}.cmds.securitydata._extract".format(PRODUCT_NAME)) + + +@pytest.fixture +def begin_option(mocker): + mock = mocker.patch("{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME)) + mock.return_value = BEGIN_TIMESTAMP + return mock + + +ADVANCED_QUERY_JSON = '{"some": "complex json"}' + + +parametrize_search_output_cmds = pytest.mark.parametrize( + "cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]] +) + +parametrize_incompatible_args = pytest.mark.parametrize( + "arg", + [ + ("--begin", "1d"), + ("--end", "1d"), + ("--c42-username", "test@code42.com"), + ("--actor", "test.testerson"), + ("--md5", "abcd1234"), + ("--sha256", "abcdefg12345678"), + ("--source", "Gmail"), + ("--file-name", "test.txt"), + ("--file-path", "C:\\Program Files"), + ("--process-owner", "root"), + ("--tab-url", "https://example.com"), + ("--type", "SharedViaLink"), + ("--include-non-exposure",), + ("--use-checkpoint", "test"), + ], +) + + +@parametrize_search_output_cmds +def test_when_is_advanced_query_uses_only_the_extract_advanced_method( + runner, cmd, cli_state, file_event_extractor +): + result = runner.invoke( + cli, ["security-data", *cmd, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + ) + file_event_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') + assert file_event_extractor.extract.call_count == 0 + assert file_event_extractor.extract_advanced.call_count == 1 + + +@parametrize_search_output_cmds +def test_when_is_advanced_query_uses_only_the_extract_advanced_method( + runner, cmd, cli_state, file_event_extractor +): + result = runner.invoke(cli, ["security-data", *cmd, "--begin", "1d"], obj=cli_state) + assert file_event_extractor.extract_advanced.call_count == 0 + assert file_event_extractor.extract.call_count == 1 + + +@parametrize_incompatible_args +def test_print_when_advanced_query_and_other_incompatible_argument_passed(runner, arg, cli_state): + result = runner.invoke( + cli, + ["security-data", "print", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +@parametrize_incompatible_args +def test_print_when_saved_search_and_other_incompatible_argument_passed(runner, arg, cli_state): + result = runner.invoke( + cli, ["security-data", "print", "--saved-search", "test_id", *arg], obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --saved-search".format(arg[0]) in result.output + + +@parametrize_incompatible_args +def test_write_to_when_advanced_query_and_other_incompatible_argument_passed( + runner, arg, cli_state +): + result = runner.invoke( + cli, + ["security-data", "write-to", "test_file", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +@parametrize_incompatible_args +def test_write_to_when_saved_search_and_other_incompatible_argument_passed(runner, arg, cli_state): + result = runner.invoke( + cli, + ["security-data", "write-to", "test_file", "--saved-search", "test_id", *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --saved-search".format(arg[0]) in result.output + + +@parametrize_incompatible_args +def test_send_to_when_advanced_query_and_other_incompatible_argument_passed(runner, arg, cli_state): + result = runner.invoke( + cli, + ["security-data", "send-to", "localhost", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +@parametrize_incompatible_args +def test_send_to_when_saved_search_and_other_incompatible_argument_passed(runner, arg, cli_state): + result = runner.invoke( + cli, + ["security-data", "send-to", "localhost", "--saved-search", "test_id", *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --saved-search".format(arg[0]) in result.output + + +@parametrize_search_output_cmds +def test_when_given_begin_and_end_dates_uses_expected_query( + runner, cmd, cli_state, file_event_extractor +): + begin_date = get_test_date_str(days_ago=89) + end_date = get_test_date_str(days_ago=1) + result = runner.invoke( + cli, ["security-data", "print", "--begin", begin_date, "--end", end_date], obj=cli_state + ) + filters = file_event_extractor.extract.call_args[0][1] + actual_begin = get_filter_value_from_json(filters, filter_index=0) + expected_begin = "{0}T00:00:00.000Z".format(begin_date) + actual_end = get_filter_value_from_json(filters, filter_index=1) + expected_end = "{0}T23:59:59.999Z".format(end_date) + assert actual_begin == expected_begin + assert actual_end == expected_end + + +@parametrize_search_output_cmds +def test_when_given_begin_and_end_date_and_time_uses_expected_query( + runner, cmd, cli_state, file_event_extractor +): + begin_date = get_test_date_str(days_ago=89) + end_date = get_test_date_str(days_ago=1) + time = "15:33:02" + result = runner.invoke( + cli, + [ + "security-data", + "print", + "--begin", + "{} {}".format(begin_date, time), + "--end", + "{} {}".format(end_date, time), + ], + obj=cli_state, + ) + filters = file_event_extractor.extract.call_args[0][1] + actual_begin = get_filter_value_from_json(filters, filter_index=0) + expected_begin = "{0}T{1}.000Z".format(begin_date, time) + actual_end = get_filter_value_from_json(filters, filter_index=1) + expected_end = "{0}T{1}.000Z".format(end_date, time) + assert actual_begin == expected_begin + assert actual_end == expected_end + + +@parametrize_search_output_cmds +def test_when_given_begin_date_and_time_without_seconds_uses_expected_query( + runner, cmd, cli_state, file_event_extractor +): + date = get_test_date_str(days_ago=89) + time = "15:33" + result = runner.invoke( + cli, ["security-data", "print", "--begin", "{} {}".format(date, time)], obj=cli_state + ) + actual = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][1], filter_index=0 + ) + expected = "{0}T{1}:00.000Z".format(date, time) + assert actual == expected + + +@parametrize_search_output_cmds +def test_when_given_end_date_and_time_uses_expected_query( + runner, cmd, cli_state, file_event_extractor +): + begin_date = get_test_date_str(days_ago=10) + end_date = get_test_date_str(days_ago=1) + time = "15:33" + result = runner.invoke( + cli, + ["security-data", "print", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], + obj=cli_state, + ) + actual = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][1], filter_index=1 + ) + expected = "{0}T{1}:00.000Z".format(end_date, time) + assert actual == expected + + +@parametrize_search_output_cmds +def test_when_given_begin_date_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( + runner, cmd, cli_state, +): + begin_date = get_test_date_str(days_ago=91) + " 12:51:00" + result = runner.invoke(cli, ["security-data", *cmd, "--begin", begin_date], obj=cli_state) + assert result.exit_code == 2 + assert "must be within 90 days" in result.output + + +@parametrize_search_output_cmds +def test_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + runner, cmd, cli_state, file_event_cursor_with_checkpoint, mocker, file_event_extractor +): + begin_date = get_test_date_str(days_ago=91) + " 12:51:00" + result = runner.invoke( + cli, + ["security-data", *cmd, "--begin", begin_date, "--use-checkpoint", "test"], + obj=cli_state, + ) + assert not filter_term_is_in_call_args(file_event_extractor, InsertionTimestamp._term) + + +@parametrize_search_output_cmds +def test_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( + runner, cmd, cli_state, file_event_extractor +): + begin_date = get_test_date_str(days_ago=1) + result = runner.invoke(cli, ["security-data", *cmd, "--begin", begin_date], obj=cli_state) + + actual_ts = get_filter_value_from_json( + file_event_extractor.extract.call_args[0][1], filter_index=0 + ) + expected_ts = "{0}T00:00:00.000Z".format(begin_date) + assert actual_ts == expected_ts + assert filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) + + +@parametrize_search_output_cmds +def test_when_end_date_is_before_begin_date_causes_exit(runner, cmd, cli_state): + begin_date = get_test_date_str(days_ago=1) + end_date = get_test_date_str(days_ago=3) + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", begin_date, "--end", end_date], obj=cli_state + ) + assert result.exit_code == 2 + assert "'--begin': cannot be after --end date" in result.output + + +def test_print_with_only_begin_calls_extract_with_expected_args( + runner, cli_state, file_event_extract_func, stdout_logger, begin_option +): + result = runner.invoke(cli, ["security-data", "print", "--begin", "1h"], obj=cli_state) + file_event_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=None, + checkpoint_name=None, + filter_list=cli_state.search_filters, + begin=BEGIN_TIMESTAMP, + end=None, + advanced_query=None, + saved_search=None, + output_logger=stdout_logger.return_value, + ) + assert result.exit_code == 0 + + +def test_send_to_with_only_begin_calls_extract_with_expected_args( + runner, cli_state, file_event_extract_func, server_logger, begin_option +): + result = runner.invoke( + cli, ["security-data", "send-to", "localhost", "--begin", "1h"], obj=cli_state + ) + file_event_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=None, + checkpoint_name=None, + filter_list=cli_state.search_filters, + begin=BEGIN_TIMESTAMP, + end=None, + advanced_query=None, + saved_search=None, + output_logger=server_logger.return_value, + ) + assert result.exit_code == 0 + + +def test_write_to_with_only_begin_calls_extract_with_expected_args( + runner, cli_state, file_event_extract_func, file_logger, begin_option +): + result = runner.invoke( + cli, ["security-data", "write-to", "test_file", "--begin", "1h"], obj=cli_state + ) + file_event_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=None, + checkpoint_name=None, + filter_list=cli_state.search_filters, + begin=BEGIN_TIMESTAMP, + end=None, + advanced_query=None, + saved_search=None, + output_logger=file_logger.return_value, + ) + assert result.exit_code == 0 + + +@parametrize_search_output_cmds +def test_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_expected_error( + runner, cmd, cli_state, file_event_cursor_without_checkpoint +): + result = runner.invoke(cli, ["security-data", *cmd, "--use-checkpoint", "test"], obj=cli_state) + assert result.exit_code == 2 + assert ( + "--begin date is required for --use-checkpoint when no checkpoint exists yet." + in result.output + ) + + +@parametrize_search_output_cmds +def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( + runner, + cmd, + cli_state, + file_event_extract_func, + begin_option, + file_event_cursor_without_checkpoint, + stdout_logger, + server_logger, + file_logger, + mocker, +): + result = runner.invoke( + cli, ["security-data", *cmd, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + ) + assert result.exit_code == 0 + file_event_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=file_event_cursor_without_checkpoint.return_value, + checkpoint_name="test", + filter_list=cli_state.search_filters, + begin=BEGIN_TIMESTAMP, + end=None, + advanced_query=None, + saved_search=None, + output_logger=mocker.ANY, + ) + + +@parametrize_search_output_cmds +def test_with_use_checkpoint_and_with_begin_and_with_checkpoint_calls_extract_with_begin_date_none( + runner, + cmd, + cli_state, + file_event_extract_func, + file_event_cursor_with_checkpoint, + stdout_logger, + server_logger, + file_logger, + mocker, +): + result = runner.invoke( + cli, ["security-data", *cmd, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + ) + assert result.exit_code == 0 + file_event_extract_func.assert_called_with( + sdk=cli_state.sdk, + cursor=file_event_cursor_with_checkpoint.return_value, + checkpoint_name="test", + filter_list=cli_state.search_filters, + begin=None, + end=None, + advanced_query=None, + saved_search=None, + output_logger=mocker.ANY, + ) + assert "checkpoint of 2020-01-20T06:00:00+00:00 exists" in result.output + + +@parametrize_search_output_cmds +def test_extract_when_given_invalid_exposure_type_causes_exit(runner, cmd, cli_state): + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1d", "-t", "NotValid"], obj=cli_state + ) + assert result.exit_code == 2 + assert "invalid choice: NotValid" in result.output + + +@parametrize_search_output_cmds +def test_when_given_username_uses_username_filter(runner, cmd, cli_state, file_event_extractor): + c42_username = "test@code42.com" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--c42-username", c42_username], obj=cli_state + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(DeviceUsername.is_in([c42_username])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_actor_is_uses_username_filter(runner, cmd, cli_state, file_event_extractor): + actor_name = "test.testerson" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--actor", actor_name], obj=cli_state + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(Actor.is_in([actor_name])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_md5_uses_md5_filter(runner, cmd, cli_state, file_event_extractor): + md5 = "abcd12345" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--md5", md5], obj=cli_state + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(MD5.is_in([md5])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_sha256_uses_sha256_filter(runner, cmd, cli_state, file_event_extractor): + sha_256 = "abcd12345" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--sha256", sha_256], obj=cli_state + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(SHA256.is_in([sha_256])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_source_uses_source_filter(runner, cmd, cli_state, file_event_extractor): + source = "Gmail" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--source", source], obj=cli_state + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(Source.is_in([source])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_file_name_uses_file_name_filter(runner, cmd, cli_state, file_event_extractor): + filename = "test.txt" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--file-name", filename], obj=cli_state + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(FileName.is_in([filename])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_file_path_uses_file_path_filter(runner, cmd, cli_state, file_event_extractor): + filepath = "C:\\Program Files" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--file-path", filepath], obj=cli_state + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(FilePath.is_in([filepath])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_process_owner_uses_process_owner_filter( + runner, cmd, cli_state, file_event_extractor +): + process_owner = "root" + result = runner.invoke( + cli, + ["security-data", *cmd, "--begin", "1h", "--process-owner", process_owner], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(ProcessOwner.is_in([process_owner])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_tab_url_uses_process_tab_url_filter( + runner, cmd, cli_state, file_event_extractor +): + tab_url = "https://example.com" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--tab-url", tab_url], obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(TabURL.is_in([tab_url])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_exposure_types_uses_exposure_type_is_in_filter( + runner, cmd, cli_state, file_event_extractor +): + exposure_type = "SharedViaLink" + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--type", exposure_type], obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(ExposureType.is_in([exposure_type])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_include_non_exposure_does_not_include_exposure_type_exists( + runner, cmd, cli_state, file_event_extractor +): + result = runner.invoke( + cli, ["security-data", *cmd, "--begin", "1h", "--include-non-exposure"], obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(ExposureType.exists()) not in filter_strings + + +@parametrize_search_output_cmds +def test_when_not_given_include_non_exposure_includes_exposure_type_exists( + runner, cmd, cli_state, file_event_extractor +): + result = runner.invoke(cli, ["security-data", *cmd, "--begin", "1h"], obj=cli_state,) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(ExposureType.exists()) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_multiple_search_args_uses_expected_filters( + runner, cmd, cli_state, file_event_extractor +): + process_owner = "root" + c42_username = "test@code42.com" + filename = "test.txt" + result = runner.invoke( + cli, + [ + "security-data", + *cmd, + "--begin", + "1h", + "--process-owner", + process_owner, + "--c42-username", + c42_username, + "--file-name", + filename, + ], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(ProcessOwner.is_in([process_owner])) in filter_strings + assert str(FileName.is_in([filename])) in filter_strings + assert str(DeviceUsername.is_in([c42_username])) in filter_strings + + +@parametrize_search_output_cmds +def test_when_given_include_non_exposure_and_exposure_types_causes_exit( + runner, cmd, cli_state, file_event_extractor +): + result = runner.invoke( + cli, + [ + "security-data", + *cmd, + "--begin", + "1h", + "--include-non-exposure", + "--type", + "SharedViaLink", + ], + obj=cli_state, + ) + assert result.exit_code == 2 + + +@parametrize_search_output_cmds +def test_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( + runner, cmd, cli_state, mocker, caplog +): + errors.ERRORED = False + exception_msg = "Test Exception" + + def file_search_error(x): + raise Exception(exception_msg) + + cli_state.sdk.securitydata.search_file_events.side_effect = file_search_error + with caplog.at_level(logging.ERROR): + result = runner.invoke(cli, ["security-data", *cmd, "--begin", "1d"], obj=cli_state) + assert exception_msg in result.output + assert exception_msg in caplog.text + assert errors.ERRORED + + +@parametrize_search_output_cmds +def test_saved_search_calls_extractor_extract_and_saved_search_execute( + runner, cmd, cli_state, file_event_extractor +): + search_query = { + "groupClause": "AND", + "groups": [ + { + "filterClause": "AND", + "filters": [ + { + "operator": "ON_OR_AFTER", + "term": "eventTimestamp", + "value": "2020-05-01T00:00:00.000Z", + } + ], + }, + { + "filterClause": "OR", + "filters": [ + {"operator": "IS", "term": "eventType", "value": "DELETED"}, + {"operator": "IS", "term": "eventType", "value": "EMAILED"}, + {"operator": "IS", "term": "eventType", "value": "MODIFIED"}, + {"operator": "IS", "term": "eventType", "value": "READ_BY_AP"}, + {"operator": "IS", "term": "eventType", "value": "CREATED"}, + ], + }, + ], + "pgNum": 1, + "pgSize": 10000, + "srtDir": "asc", + "srtKey": "eventId", + } + query = FileEventQuery.from_dict(search_query) + cli_state.sdk.securitydata.savedsearches.get_query.return_value = query + result = runner.invoke(cli, ["security-data", *cmd, "--saved-search", "test_id"], obj=cli_state) + assert file_event_extractor.extract.call_count == 1 + assert str(file_event_extractor.extract.call_args[0][0]) in str(query) + assert str(file_event_extractor.extract.call_args[0][1]) in str(query) + + +def test_saved_search_list_calls_get_method(runner, cli_state): + result = runner.invoke(cli, ["security-data", "saved-search", "list"], obj=cli_state) + assert cli_state.sdk.securitydata.savedsearches.get.call_count == 1 + + +def test_show_detail_calls_get_by_id_method(runner, cli_state): + test_id = "test_id" + result = runner.invoke(cli, ["security-data", "saved-search", "show", test_id], obj=cli_state) + cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with(test_id) diff --git a/tests/cmds/test_shared.py b/tests/cmds/test_shared.py new file mode 100644 index 000000000..6d6ddcab0 --- /dev/null +++ b/tests/cmds/test_shared.py @@ -0,0 +1,9 @@ +import pytest + +from code42cli.cmds.shared import get_user_id +from code42cli.errors import UserDoesNotExistError + + +def test_get_user_id_when_user_does_not_raise_error(sdk_without_user): + with pytest.raises(UserDoesNotExistError): + get_user_id(sdk_without_user, "risky employee") diff --git a/tests/conftest.py b/tests/conftest.py index 58be055f3..ff3e1d543 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,18 @@ from datetime import datetime, timedelta import pytest +from click.testing import CliRunner from py42.sdk import SDKClient -from code42cli.bulk import BulkProcessor -from code42cli.file_readers import CliFileReader +import code42cli.errors as error_tracker from code42cli.config import ConfigAccessor +from code42cli.options import CLIState from code42cli.profile import Code42Profile -from code42cli.commands import DictObject, Command, SubcommandLoader -import code42cli.errors as error_tracker + +@pytest.fixture +def runner(): + return CliRunner() @pytest.fixture(autouse=True) @@ -19,63 +22,59 @@ def io_prevention(monkeypatch): @pytest.fixture def file_event_namespace(): - args = DictObject( - dict( - sdk=mock_42, - profile=create_mock_profile(), - use_checkpoint=None, - advanced_query=None, - begin=None, - end=None, - type=None, - c42_username=None, - actor=None, - md5=None, - sha256=None, - source=None, - file_name=None, - file_path=None, - process_owner=None, - tab_url=None, - include_non_exposure=None, - format=None, - output_file=None, - server=None, - protocol=None, - saved_search=None, - ) + args = dict( + sdk=mock_42, + profile=create_mock_profile(), + incremental=None, + advanced_query=None, + begin=None, + end=None, + type=None, + c42_username=None, + actor=None, + md5=None, + sha256=None, + source=None, + file_name=None, + file_path=None, + process_owner=None, + tab_url=None, + include_non_exposure=None, + format=None, + output_file=None, + server=None, + protocol=None, + saved_search=None, ) return args @pytest.fixture def alert_namespace(): - args = DictObject( - dict( - sdk=mock_42, - profile=create_mock_profile(), - use_checkpoint=None, - advanced_query=None, - begin=None, - end=None, - severity=None, - state=None, - actor=None, - actor_contains=None, - exclude_actor=None, - exclude_actor_contains=None, - rule_name=None, - exclude_rule_name=None, - rule_id=None, - exclude_rule_id=None, - rule_type=None, - exclude_rule_type=None, - description=None, - format=None, - output_file=None, - server=None, - protocol=None, - ) + args = dict( + sdk=mock_42, + profile=create_mock_profile(), + incremental=None, + advanced_query=None, + begin=None, + end=None, + severity=None, + state=None, + actor=None, + actor_contains=None, + exclude_actor=None, + exclude_actor_contains=None, + rule_name=None, + exclude_rule_name=None, + rule_id=None, + exclude_rule_id=None, + rule_type=None, + exclude_rule_type=None, + description=None, + format=None, + output_file=None, + server=None, + protocol=None, ) return args @@ -108,11 +107,20 @@ def sdk_without_user(sdk): return sdk -@pytest.fixture() +@pytest.fixture def mock_42(mocker): return mocker.patch("py42.sdk.from_local_account") +@pytest.fixture +def cli_state(mocker, sdk, profile): + mock_state = mocker.MagicMock(spec=CLIState) + mock_state._sdk = sdk + mock_state.profile = profile + mock_state.search_filters = [] + return mock_state + + class MockSection(object): def __init__(self, name=None, values_dict=None): self.name = name @@ -234,24 +242,3 @@ def __exit__(self, exc_type, exc_val, exc_tb): TEST_FILE_PATH = "some/path" - - -def create_mock_reader(rows): - class MockDictReader(CliFileReader): - def __call__(self, *args, **kwargs): - return rows - - def get_rows_count(self): - return len(rows) - - return MockDictReader(TEST_FILE_PATH) - - -subcommand1 = Command("sub1", "sub1 desc", "sub1 usage") -subcommand2 = Command("sub2", "sub2 desc", "sub2 usage") -subcommand3 = Command("sub3", "sub3 desc", "sub3 usage") - - -class DummySubcommandLoader(SubcommandLoader): - def load_commands(self): - return [subcommand1, subcommand2, subcommand3] diff --git a/tests/test_args.py b/tests/test_args.py deleted file mode 100644 index 5a81cfe0a..000000000 --- a/tests/test_args.py +++ /dev/null @@ -1,103 +0,0 @@ -from code42cli.args import ArgConfig, ArgConfigCollection - - -class TestArgConfig(object): - def test_param_names_accessible_on_options_list(self): - arg_config = ArgConfig("-t", "--test") - names = arg_config.settings["options_list"] - assert len(names) == 2 - assert names[0] == "-t" - assert names[1] == "--test" - - def test_action_accessible(self): - arg_config = ArgConfig("-t", "--test", action="store") - assert arg_config.settings["action"] == "store" - - def test_choices_accessible(self): - choices = ["one", "two"] - arg_config = ArgConfig("-t", "--test", choices=choices) - assert arg_config.settings["choices"] == choices - - def test_default_accessible(self): - default = "testdefault" - arg_config = ArgConfig("-t", "--test", default=default) - assert arg_config.settings["default"] == default - - def test_help_accessible(self): - help = "testhelp" - arg_config = ArgConfig("-t", "--test", help=help) - assert arg_config.settings["help"] == help - - def test_nargs_accessible(self): - nargs = "+" - arg_config = ArgConfig("-t", "--test", nargs=nargs) - assert arg_config.settings["nargs"] == nargs - - def test_set_choices_modifies_choices(self): - choices = ["something", "another"] - arg_config = ArgConfig("-t", "--test") - arg_config.set_choices(choices) - assert arg_config.settings["choices"] == choices - - def test_set_help_modifies_help(self): - help = "testhelp" - arg_config = ArgConfig("-t", "--test") - arg_config.set_help(help) - assert arg_config.settings["help"] == help - - def test_add_short_option_name_modifies_options_list(self): - arg_config = ArgConfig("--test") - arg_config.add_short_option_name("-x") - assert "-x" in arg_config.settings["options_list"] - - def test_as_multi_val_param_modifies_nargs(self): - nargs = "*" - arg_config = ArgConfig("-t", "--test") - arg_config.as_multi_val_param(nargs) - assert arg_config.settings["nargs"] == "*" - - def test_as_multi_val_params_when_default_modifies_nargs_to_be_plus(self): - arg_config = ArgConfig("-t", "--test") - arg_config.as_multi_val_param() - assert arg_config.settings["nargs"] == "+" - - -class TestArgConfigCollection(object): - def test_add_adds_arg_config(self): - arg_config = ArgConfig() - coll = ArgConfigCollection() - coll.append("test", arg_config) - assert coll.arg_configs["test"] == arg_config - - def test_extends_adds_multiple_arg_configs(self): - configs = {} - arg_config1 = ArgConfig() - arg_config2 = ArgConfig() - configs["one"] = arg_config1 - configs["two"] = arg_config2 - - coll = ArgConfigCollection() - coll.extend(configs) - assert len(coll.arg_configs) == 2 - assert coll.arg_configs["one"] == arg_config1 - assert coll.arg_configs["two"] == arg_config2 - - def test_arg_configs_are_in_same_order_as_added(self): - arg_config1 = ArgConfig("--test") - arg_config2 = ArgConfig("--test2") - arg_config3 = ArgConfig("--test3") - - coll = ArgConfigCollection() - coll.append("test3", arg_config3) - coll.append("test1", arg_config1) - coll.append("test2", arg_config2) - - for position, key in enumerate(coll.arg_configs): - if position == 0: - assert coll.arg_configs[key] == arg_config3 - - if position == 1: - assert coll.arg_configs[key] == arg_config1 - - if position == 2: - assert coll.arg_configs[key] == arg_config2 diff --git a/tests/test_bulk.py b/tests/test_bulk.py index cc74aafbd..a912bf59c 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -1,27 +1,15 @@ from collections import OrderedDict -from io import IOBase + import pytest -import logging from code42cli import PRODUCT_NAME -from code42cli import errors as errors -from code42cli.bulk import generate_template, BulkProcessor, run_bulk_process -from code42cli.logger import get_view_exceptions_location_message -from code42cli.progress_bar import ProgressBar - -from .conftest import ErrorTrackerTestHelper, create_mock_reader - +from code42cli import errors +from code42cli.bulk import BulkProcessor, run_bulk_process +from code42cli.logger import get_view_error_details_message _NAMESPACE = "{}.bulk".format(PRODUCT_NAME) -@pytest.fixture -def mock_open(mocker): - mock = mocker.patch("{}.open".format(_NAMESPACE)) - mock.return_value = mocker.MagicMock(spec=IOBase) - return mock - - @pytest.fixture def bulk_processor(mocker): return mocker.MagicMock(spec=BulkProcessor) @@ -34,11 +22,6 @@ def bulk_processor_factory(mocker, bulk_processor): return mock_factory -@pytest.fixture -def progress_bar(mocker): - return mocker.MagicMock(spec=ProgressBar) - - def func_with_multiple_args(sdk, profile, test1, test2): pass @@ -47,44 +30,6 @@ def func_with_one_arg(sdk, profile, test1): pass -def test_generate_template_uses_expected_path_and_column_names(mock_open): - file_path = "some/path" - template_file = mock_open.return_value.__enter__.return_value - - generate_template(func_with_multiple_args, file_path) - mock_open.assert_called_once_with(file_path, u"w", encoding=u"utf8") - template_file.write.assert_called_once_with("test1,test2") - - -def test_generate_template_when_handler_has_one_arg_creates_file_without_columns(mock_open): - file_path = "some/path" - template_file = mock_open.return_value.__enter__.return_value - - generate_template(func_with_one_arg, "some/path") - mock_open.assert_called_once_with(file_path, u"w", encoding=u"utf8") - assert not template_file.write.call_count - - -def test_generate_template_when_handler_has_one_arg_prints_message(mock_open, caplog): - with caplog.at_level(logging.INFO): - generate_template(func_with_one_arg, "some/path") - assert ( - u"A blank file was generated because there are no csv headers needed for this command. " - u"Simply enter one test1 per line." in caplog.text - ) - - -def test_generate_template_when_handler_has_more_than_one_arg_does_not_print_message( - mock_open, capsys -): - generate_template(func_with_multiple_args, "some/path") - capture = capsys.readouterr() - assert ( - u"A blank file was generated because there are no csv headers needed for this command type." - not in capture.out - ) - - def test_run_bulk_process_calls_run(bulk_processor, bulk_processor_factory): errors.ERRORED = False run_bulk_process(func_with_one_arg, None) @@ -93,142 +38,111 @@ def test_run_bulk_process_calls_run(bulk_processor, bulk_processor_factory): def test_run_bulk_process_creates_processor(bulk_processor_factory): errors.ERRORED = False - reader = create_mock_reader([1, 2]) - run_bulk_process(func_with_one_arg, reader) - bulk_processor_factory.assert_called_once_with(func_with_one_arg, reader) + rows = [1, 2] + run_bulk_process(func_with_one_arg, rows) + bulk_processor_factory.assert_called_once_with(func_with_one_arg, rows, None) class TestBulkProcessor(object): - def test_run_when_reader_returns_ordered_dict_process_kwargs(self, mock_open, progress_bar): + def test_run_when_reader_returns_ordered_dict_process_kwargs(self): processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - reader = create_mock_reader( - [ - OrderedDict({"test1": 1, "test2": 2}), - OrderedDict({"test1": 3, "test2": 4}), - OrderedDict({"test1": 5, "test2": 6}), - ] - ) - processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + rows = [ + OrderedDict({"test1": 1, "test2": 2}), + OrderedDict({"test1": 3, "test2": 4}), + OrderedDict({"test1": 5, "test2": 6}), + ] + processor = BulkProcessor(func_for_bulk, rows) processor.run() assert (1, 2) in processed_rows assert (3, 4) in processed_rows assert (5, 6) in processed_rows - def test_run_when_reader_returns_dict_process_kwargs(self, mock_open, progress_bar): + def test_run_when_reader_returns_dict_process_kwargs(self): processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - reader = create_mock_reader( - [{"test1": 1, "test2": 2}, {"test1": 3, "test2": 4}, {"test1": 5, "test2": 6}] - ) - processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + rows = [{"test1": 1, "test2": 2}, {"test1": 3, "test2": 4}, {"test1": 5, "test2": 6}] + processor = BulkProcessor(func_for_bulk, rows) processor.run() assert (1, 2) in processed_rows assert (3, 4) in processed_rows assert (5, 6) in processed_rows - def test_run_when_dict_reader_has_none_for_key_ignores_key(self, mock_open, progress_bar): + def test_run_when_dict_reader_has_none_for_key_ignores_key(self): processed_rows = [] def func_for_bulk(test1): processed_rows.append(test1) - reader = create_mock_reader([{"test1": 1, None: 2}]) - processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + rows = [{"test1": 1, None: 2}] + processor = BulkProcessor(func_for_bulk, rows) processor.run() assert processed_rows == [1] - def test_run_when_reader_returns_strs_processes_strs(self, mock_open, progress_bar): + def test_run_when_reader_returns_strs_processes_strs(self): processed_rows = [] def func_for_bulk(test): processed_rows.append(test) - reader = create_mock_reader(["row1", "row2", "row3"]) - processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + rows = ["row1", "row2", "row3"] + processor = BulkProcessor(func_for_bulk, rows) processor.run() assert "row1" in processed_rows assert "row2" in processed_rows assert "row3" in processed_rows - def test_run_when_error_occurs_prints_error_messages(self, mock_open, caplog): - caplog.set_level(logging.INFO) - + def test_run_when_error_occurs_raises_expected_logged_cli_error(self): def func_for_bulk(test): if test == "row2": raise Exception() - reader = create_mock_reader(["row1", "row2", "row3"]) - with ErrorTrackerTestHelper(): - processor = BulkProcessor(func_for_bulk, reader) + rows = ["row1", "row2", "row3"] + with pytest.raises(errors.LoggedCLIError) as err: + processor = BulkProcessor(func_for_bulk, rows) processor.run() - with caplog.at_level(logging.ERROR): - assert get_view_exceptions_location_message() in caplog.text - - def test_run_when_no_errors_occur_prints_success_messages(self, mock_open, caplog): - def func_for_bulk(test): - pass - - reader = create_mock_reader(["row1", "row2", "row3"]) - processor = BulkProcessor(func_for_bulk, reader) - processor.run() - with caplog.at_level(logging.INFO): - assert "3 succeeded, 0 failed out of 3" in caplog.text + assert err.value.message == "Some problems occurred during bulk processing." - def test_run_when_no_errors_occur_does_not_print_error_message( - self, mock_open, caplog, progress_bar - ): + def test_run_when_no_errors_occur_does_not_print_error_message(self, capsys): def func_for_bulk(test): pass - reader = create_mock_reader(["row1", "row2", "row3"]) - processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + rows = ["row1", "row2", "row3"] + processor = BulkProcessor(func_for_bulk, rows) - with caplog.at_level(logging.ERROR): - processor.run() - assert get_view_exceptions_location_message() not in caplog.text + processor.run() + output = capsys.readouterr() + assert get_view_error_details_message() not in output.out - def test_run_when_row_is_endline_does_not_process_row(self, mock_open, progress_bar): + def test_run_when_row_is_endline_does_not_process_row(self): processed_rows = [] def func_for_bulk(test): processed_rows.append(test) - reader = create_mock_reader(["row1", "row2", "\n"]) - processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + rows = ["row1", "row2", "\n"] + processor = BulkProcessor(func_for_bulk, rows) processor.run() assert "row1" in processed_rows assert "row2" in processed_rows assert "row3" not in processed_rows - def test_run_when_reader_returns_dict_rows_containing_empty_strs_converts_them_to_none( - self, mock_open, progress_bar - ): + def test_run_when_reader_returns_dict_rows_containing_empty_strs_converts_them_to_none(self): processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - reader = create_mock_reader([{"test1": "", "test2": "foo"}, {"test1": "bar", "test2": u""}]) - processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) + rows = [{"test1": "", "test2": "foo"}, {"test1": "bar", "test2": u""}] + processor = BulkProcessor(func_for_bulk, rows) processor.run() assert (None, "foo") in processed_rows assert ("bar", None) in processed_rows - - # def test_run_updates_progress_bar_once_per_row(self, mock_open, progress_bar): - # def func_for_bulk(*args, **kwargs): - # pass - # - # rows = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] - # reader = create_mock_reader(rows) - # processor = BulkProcessor(func_for_bulk, reader, progress_bar=progress_bar) - # processor.run() - # assert progress_bar.update.call_count == len(rows) diff --git a/tests/test_commands.py b/tests/test_commands.py deleted file mode 100644 index 8ce2e6b57..000000000 --- a/tests/test_commands.py +++ /dev/null @@ -1,293 +0,0 @@ -import pytest -from py42.sdk import SDKClient - -from code42cli import PRODUCT_NAME -from code42cli.args import ArgConfig, SDK_ARG_NAME, PROFILE_ARG_NAME -from code42cli.commands import Command, DictObject, SubcommandLoader -from code42cli.profile import Code42Profile -from .conftest import ( - func_keyword_args, - func_mixed_args, - func_positional_args, - func_with_args, - func_with_sdk, - func_single_positional_arg_with_sdk_and_profile, - func_single_positional_arg, - func_single_positional_arg_many_optional_args, - subcommand1, - subcommand2, - subcommand3, - DummySubcommandLoader, -) - - -def arg_customizer(arg_collection): - arg_collection.append("success", ArgConfig("--success")) - - -@pytest.fixture -def mock_profile_reader(mocker): - return mocker.patch("{}.profile.get_profile".format(PRODUCT_NAME)) - - -@pytest.fixture -def mock_sdk_client(mocker, mock_42): - client = mocker.MagicMock(spec=SDKClient) - mock_42.return_value = client - return client - - -class TestCommand(object): - def test_name(self): - command = Command("test", "test desc", "test usage") - assert command.name == "test" - - def test_description(self): - command = Command("test", "test desc", "test usage") - assert command.description == "test desc" - - def test_usage(self): - command = Command("test", "test desc", "test usage") - assert command.usage == "test usage" - - def test_load_subcommands_makes_subcommands_accessible(self): - command = Command( - "test", "test desc", "test usage", subcommand_loader=DummySubcommandLoader("test") - ) - command.load_subcommands() - assert len(command.subcommands) == 3 - assert subcommand1 in command.subcommands - assert subcommand2 in command.subcommands - assert subcommand3 in command.subcommands - - def test_load_subcommands_when_no_loader_does_nothing(self): - command = Command("test", "test desc", "test usage") - command.load_subcommands() - assert not len(command.subcommands) - - def test_get_arg_configs_when_no_func_returns_empty_collection(self): - command = Command("test", "test desc", "test usage") - coll = command.get_arg_configs() - assert not coll - - def test_get_arg_configs_calls_arg_customizer_if_present(self): - command = Command("test", "test desc", "test usage", func_keyword_args, arg_customizer) - coll = command.get_arg_configs() - assert "success" in coll - - def test_get_arg_configs_when_keyword_args_returns_expected_collection(self): - command = Command("test", "test desc", "test usage", func_keyword_args) - coll = command.get_arg_configs() - assert "--one" in coll["one"].settings["options_list"] - assert "--two" in coll["two"].settings["options_list"] - assert "--three" in coll["three"].settings["options_list"] - assert "--default" in coll["default"].settings["options_list"] - - def test_get_arg_configs_when_keyword_args_has_defaults_set(self): - command = Command("test", "test desc", "test usage", func_keyword_args) - coll = command.get_arg_configs() - assert coll["default"].settings["default"] == "testdefault" - - def test_get_arg_configs_when_positional_args_returns_expected_collection(self): - command = Command("test", "test desc", "test usage", func_positional_args) - coll = command.get_arg_configs() - assert "--one" in coll["one"].settings["options_list"] - assert "--two" in coll["two"].settings["options_list"] - assert "--three" in coll["three"].settings["options_list"] - - def test_get_arg_configs_when_positional_args_returns_all_required_args(self): - command = Command("test", "test desc", "test usage", func_positional_args) - coll = command.get_arg_configs() - assert coll["one"].settings["required"] == True - assert coll["two"].settings["required"] == True - assert coll["three"].settings["required"] == True - - def test_get_arg_configs_when_mixed_args_returns_expected_collection(self): - command = Command("test", "test desc", "test usage", func_mixed_args) - coll = command.get_arg_configs() - assert "--one" in coll["one"].settings["options_list"] - assert "--two" in coll["two"].settings["options_list"] - assert "--three" in coll["three"].settings["options_list"] - assert "--four" in coll["four"].settings["options_list"] - - def test_get_arg_configs_when_mixed_args_returns_positional_args_required(self): - command = Command("test", "test desc", "test usage", func_mixed_args) - coll = command.get_arg_configs() - assert coll["one"].settings["required"] == True - assert coll["two"].settings["required"] == True - assert not coll["three"].settings["required"] - assert not coll["four"].settings["required"] - - def test_get_arg_configs_when_handler_with_sdk_includes_profile_and_debug(self): - command = Command("test", "test desc", "test usage", func_with_sdk) - coll = command.get_arg_configs() - assert "--one" in coll["one"].settings["options_list"] - assert "--two" in coll["two"].settings["options_list"] - assert "--three" in coll["three"].settings["options_list"] - assert "--four" in coll["four"].settings["options_list"] - assert "--profile" in coll[PROFILE_ARG_NAME].settings["options_list"] - assert "--debug" in coll["debug"].settings["options_list"] - assert not coll.get(SDK_ARG_NAME) - - def test_get_arg_configs_when_handler_with_args_excludes_args(self): - command = Command("test", "test desc", "test usage", func_with_args) - coll = command.get_arg_configs() - assert not coll.get("args") - - def test_get_arg_configs_when_handler_has_single_positional_arg_and_sdk_and_profile_returns_expected_collection( - self - ): - command = Command( - "test", "test desc", "test usage", func_single_positional_arg_with_sdk_and_profile - ) - coll = command.get_arg_configs() - assert "one" in coll["one"].settings["options_list"] - - def test_get_arg_configs_when_handler_has_single_positional_arg_returns_expected_collection( - self - ): - command = Command("test", "test desc", "test usage", func_single_positional_arg) - coll = command.get_arg_configs() - assert "one" in coll["one"].settings["options_list"] - - def test_get_arg_configs_when_handler_has_single_positional_arg_and_many_optional_args_returns_expected_collection( - self - ): - command = Command( - "test", "test desc", "test usage", func_single_positional_arg_many_optional_args - ) - coll = command.get_arg_configs() - assert "one" in coll["one"].settings["options_list"] - assert "--two" in coll["two"].settings["options_list"] - assert "--three" in coll["three"].settings["options_list"] - assert "--four" in coll["four"].settings["options_list"] - - def test_get_arg_configs_when_handler_has_single_positional_arg_and_many_optional_args_optional_args_are_not_required( - self - ): - command = Command( - "test", "test desc", "test usage", func_single_positional_arg_many_optional_args - ) - coll = command.get_arg_configs() - assert not coll["two"].settings["required"] - assert not coll["three"].settings["required"] - assert not coll["four"].settings["required"] - - def test_call_when_keyword_args_passes_expected_values(self, mocker): - def test_handler(one=None, two=None, three=None): - if one == "testone" and two == "testtwo" and three == "testthree": - return "success" - - command = Command("test", "test desc", "test usage", test_handler) - kvps = {"one": "testone", "two": "testtwo", "three": "testthree"} - kvps = DictObject(kvps) - assert command(kvps) == "success" - - def test_call_when_positional_args_passes_expected_values(self, mocker): - def test_handler(one, two, three): - if one == "testone" and two == "testtwo" and three == "testthree": - return "success" - - command = Command("test", "test desc", "test usage", test_handler) - kvps = {"one": "testone", "two": "testtwo", "three": "testthree"} - kvps = DictObject(kvps) - assert command(kvps) == "success" - - def test_call_when_both_positional_and_optional_args_passes_expected_values(self, mocker): - def test_handler(one, two, three=None, four=None): - if ( - one == "testone" - and two == "testtwo" - and three == "testthree" - and four == "testfour" - ): - return "success" - - command = Command("test", "test desc", "test usage", test_handler) - kvps = {"one": "testone", "two": "testtwo", "three": "testthree", "four": "testfour"} - kvps = DictObject(kvps) - assert command(kvps) == "success" - - def test_call_when_handler_with_sdk_passes_expected_values( - self, mocker, mock_sdk_client, mock_profile_reader - ): - def test_handler(sdk, one, two, three=None, four_underscore=None): - if ( - sdk == mock_sdk_client - and one == "testone" - and two == "testtwo" - and three == "testthree" - and four_underscore == "testfour" - ): - return "success" - - command = Command("test", "test desc", "test usage", test_handler) - kvps = { - "one": "testone", - "two": "testtwo", - "three": "testthree", - "four-underscore": "testfour", - } - kvps = DictObject(kvps) - assert command(kvps) == "success" - - def test_call_when_handler_with_sdk_and_profile_passes_expected_values( - self, mocker, mock_sdk_client, mock_profile_reader - ): - mock_profile = mocker.MagicMock(spec=Code42Profile) - mock_profile_reader.return_value = mock_profile - - def test_handler(sdk, profile, one, two, three=None, four=None): - if ( - sdk == mock_sdk_client - and profile == mock_profile - and one == "testone" - and two == "testtwo" - and three == "testthree" - and four == "testfour" - ): - return "success" - - command = Command("test", "test desc", "test usage", test_handler) - kvps = {"one": "testone", "two": "testtwo", "three": "testthree", "four": "testfour"} - kvps = DictObject(kvps) - assert command(kvps) == "success" - - def test_call_when_handler_with_args_calls_with_single_obj_with_expected_values(self): - def test_handler(args): - if args.one == "testone" and args.two == "testtwo" and args.three == "testthree": - return "success" - - command = Command("test", "test desc", "test usage", test_handler, use_single_arg_obj=True) - kvps = {"one": "testone", "two": "testtwo", "three": "testthree"} - kvps = DictObject(kvps) - assert command(kvps) == "success" - - def test_call_func_with_no_handler_and_print_help_prints_help(self): - def dummy_print_help(): - return "success" - - command = Command("test", "test desc", "test usage") - assert command(help_func=dummy_print_help) == "success" - - -class TestSubcommandLoader(object): - def test_names_when_no_subcommands_returns_nothing(self): - subcommand_loader = SubcommandLoader("") - assert not subcommand_loader.names - - def test_names_returns_expected_names(self): - subcommand_loader = SubcommandLoader("") - subcommand_loader.load_commands = lambda: [ - Command("c1", ""), - Command("c2", ""), - Command("c3", ""), - ] - assert subcommand_loader.names == ["c1", "c2", "c3"] - - def test_getitem_returns_expected_subtree(self): - subcommand_loader = SubcommandLoader("") - command = Command("c1", "", subcommand_loader=DummySubcommandLoader("")) - subcommand_loader.load_commands = lambda: [command] - assert subcommand_loader.names == ["c1"] - assert subcommand_loader["c1"].names == ["sub1", "sub2", "sub3"] diff --git a/tests/test_completer.py b/tests/test_completer.py deleted file mode 100644 index ab8bd3b86..000000000 --- a/tests/test_completer.py +++ /dev/null @@ -1,172 +0,0 @@ -import pytest - -from code42cli.completer import Completer -from code42cli.main import MainSubcommandLoader - - -@pytest.fixture -def files(mocker): - return mocker.patch("code42cli.completer.get_files_in_path") - - -class TestCompleter(object): - _completer = Completer() - - def test_complete_main_returns_empty_list(self): - actual = self._completer.complete("code4") - assert [] == actual - - def test_complete_for_profile(self): - actual = self._completer.complete("code42 profi") - assert "profile" in actual - - def test_complete_for_security_data(self): - actual = self._completer.complete("code42 security") - assert "security-data" in actual - assert len(actual) == 1 - - def test_complete_for_alert_and_rules(self): - actual = self._completer.complete("code42 al") - assert "alerts" in actual - assert "alert-rules" in actual - assert len(actual) == 2 - - def test_complete_for_departing_employee(self): - actual = self._completer.complete("code42 de") - assert "departing-employee" in actual - assert len(actual) == 1 - - def test_complete_for_high_risk_employee(self): - actual = self._completer.complete("code42 hi") - assert "high-risk-employee" in actual - assert len(actual) == 1 - - def test_profile_create(self): - actual = self._completer.complete("code42 profile cre") - assert "create" in actual - assert len(actual) == 1 - - def test_complete_for_high_risk_employee_bulk(self): - actual = self._completer.complete("code42 high-risk-employee bu") - assert "bulk" in actual - assert len(actual) == 1 - - def test_complete_for_departing_employee_bulk(self): - actual = self._completer.complete("code42 departing-employee bu") - assert "bulk" in actual - assert len(actual) == 1 - - def test_complete_for_alert_rules_bulk(self): - actual = self._completer.complete("code42 alert-rules b") - assert "bulk" in actual - assert len(actual) == 1 - - def test_complete_for_high_risk_employee_bulk_gen_template(self): - actual = self._completer.complete("code42 high-risk-employee bulk gen") - assert "generate-template" in actual - assert len(actual) == 1 - - def test_complete_for_departing_employee_bulk_gen_template(self): - actual = self._completer.complete("code42 departing-employee bulk generate-") - assert "generate-template" in actual - assert len(actual) == 1 - - def test_complete_for_alert_rules_bulk_gen_template(self): - actual = self._completer.complete("code42 alert-rules bulk gen") - assert "generate-template" in actual - assert len(actual) == 1 - - def test_complete_when_arg_is_first_and_complete_returns_first_set_of_options(self): - actual = self._completer.complete("code42 ") - assert "profile" in actual - assert "alerts" in actual - assert "alert-rules" in actual - assert "security-data" in actual - assert "departing-employee" in actual - assert "high-risk-employee" in actual - - def test_complete_when_arg_is_complete_returns_next_options(self): - actual = self._completer.complete("code42 departing-employee bulk") - assert "generate-template" in actual - assert "add" in actual - assert "remove" in actual - - def test_complete_when_arg_is_complete_and_ends_in_space_returns_next_options(self): - actual = self._completer.complete("code42 departing-employee bulk ") - assert "generate-template" in actual - assert "add" in actual - assert "remove" in actual - - def test_complete_when_error_occurs_returns_empty_list(self, mocker): - loader = mocker.MagicMock(spec=MainSubcommandLoader) - completer = Completer(loader) - actual = completer.complete("code42 dep") - assert not actual - - def test_complete_when_completing_arg_works(self): - actual = self._completer.complete("code42 security-data print --use-c") - assert "--use-checkpoint" in actual - - def test_complete_does_not_complete_positional_args(self): - actual = self._completer.complete("code42 profile use nam") - assert "name" not in actual - - def test_complete_completes_choices(self): - actual = self._completer.complete("code42 security-data send-to 127.0.0.1 -p U") - assert "UDP" in actual - - def test_complete_when_names_contains_filename_and_current_is_positional_completes_with_local_filenames( - self, files - ): - files.return_value = ["foo.txt", "bar.csv"] - actual = self._completer.complete("code42 security-data write-to ") - assert "foo.txt" in actual - assert "bar.csv" in actual - - def test_complete_when_names_contains_file_name_and_current_is_positional_completes_with_local_filenames( - self, files - ): - files.return_value = ["foo.txt", "bar.csv"] - actual = self._completer.complete("code42 alert-rules bulk add ") - assert "foo.txt" in actual - assert "bar.csv" in actual - - def test_complete_completes_local_files(self, files): - files.return_value = ["foo.txt", "bar.csv"] - actual = self._completer.complete("code42 security-data write-to foo.t") - assert "foo.txt" in actual - assert len(actual) == 1 - - def test_complete_when_current_is_prefix_to_local_file_but_is_not_arg_does_not_complete_with_local_file( - self, files - ): - files.return_value = ["bulk.txt"] - actual = self._completer.complete("code42 departing-employee bu") - assert "bulk.txt" not in actual - assert "bulk" in actual - - def test_complete_when_nothing_matches_top_level_command_returns_nothing(self): - actual = self._completer.complete("code42 XX") - assert not actual - - def test_complete_when_nothing_matches_second_level_commands_returns_nothing(self): - actual = self._completer.complete("code42 security-data prX") - assert not actual - - def test_complete_when_nothing_matches_flagged_arg_returns_nothing(self): - actual = self._completer.complete("code42 security-data print --begX") - assert not actual - - def test_complete_when_nothing_matches_choice_returns_nothing(self): - actual = self._completer.complete("code42 security-data send-to -p XX") - assert not actual - - def test_complete_when_nothing_matches_files_return_nothing(self, files): - files.return_value = ["bulk.txt"] - actual = self._completer.complete("code42 departing-employee buX") - assert not actual - - def test_completer_ignore_shorthand_flagged_args(self): - actual = self._completer.complete("code42 alerts write-to -") - assert "-i" not in actual - assert "--use-checkpoint" in actual diff --git a/tests/test_config.py b/tests/test_config.py index be171e72c..6f1956f38 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,6 +13,11 @@ _INTERNAL = "Internal" +@pytest.fixture(autouse=True) +def mock_saver(mocker): + return mocker.patch("code42cli.config.open") + + @pytest.fixture def mock_config_parser(mocker): return mocker.MagicMock(sepc=ConfigParser) @@ -55,11 +60,6 @@ def side_effect(): return mock_config_parser -@pytest.fixture(autouse=True) -def mock_saver(mocker): - return mocker.patch("{}.util.open_file".format(PRODUCT_NAME)) - - def create_mock_profile_object(profile_name, authority_url=None, username=None): mock_profile = MockSection(profile_name) mock_profile[ConfigAccessor.AUTHORITY_KEY] = authority_url @@ -144,12 +144,12 @@ def test_switch_default_profile_saves(self, config_parser_for_multiple_profiles, assert mock_saver.call_count def test_switch_default_profile_outputs_confirmation( - self, caplog, config_parser_for_multiple_profiles, mock_saver + self, capsys, config_parser_for_multiple_profiles, mock_saver ): accessor = ConfigAccessor(config_parser_for_multiple_profiles) - with caplog.at_level(logging.INFO): - accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) - assert "set as the default profile" in caplog.text + accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) + output = capsys.readouterr() + assert "set as the default profile" in output.out def test_create_profile_when_given_default_name_does_not_create(self, config_parser_for_create): accessor = ConfigAccessor(config_parser_for_create) @@ -190,15 +190,14 @@ def test_create_profile_when_not_existing_saves(self, config_parser_for_create, assert mock_saver.call_count def test_create_profile_when_not_existing_outputs_confirmation( - self, caplog, config_parser_for_create, mock_saver + self, capsys, config_parser_for_create, mock_saver ): mock_internal = create_internal_object(False) setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) - - with caplog.at_level(logging.INFO): - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) - assert "Successfully saved" in caplog.text + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) + output = capsys.readouterr() + assert "Successfully saved" in output.out def test_update_profile_when_no_profile_exists_raises_exception( self, config_parser_for_multiple_profiles diff --git a/tests/cmds/search_shared/test_cursor_store.py b/tests/test_cursor_store.py similarity index 90% rename from tests/cmds/search_shared/test_cursor_store.py rename to tests/test_cursor_store.py index 7b351125f..f7c92d10a 100644 --- a/tests/cmds/search_shared/test_cursor_store.py +++ b/tests/test_cursor_store.py @@ -5,12 +5,12 @@ from code42cli import PRODUCT_NAME from code42cli.errors import Code42CLIError -from code42cli.cmds.search_shared.cursor_store import Cursor, AlertCursorStore, FileEventCursorStore +from code42cli.cmds.search.cursor_store import Cursor, AlertCursorStore, FileEventCursorStore PROFILE_NAME = "testprofile" CURSOR_NAME = "testcursor" -_NAMESPACE = "{}.cmds.search_shared.cursor_store".format(PRODUCT_NAME) +_NAMESPACE = "{}.cmds.search.cursor_store".format(PRODUCT_NAME) @pytest.fixture @@ -57,28 +57,28 @@ def test_get_when_profile_does_not_exist_returns_none(self, mocker): def test_get_reads_expected_file(self, mock_open): store = AlertCursorStore(PROFILE_NAME) store.get(CURSOR_NAME) - user_path = path.expanduser("~/.code42cli") + user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, CURSOR_NAME) mock_open.assert_called_once_with(expected_path) def test_replace_writes_to_expected_file(self, mock_open): store = AlertCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) - user_path = path.expanduser("~/.code42cli") + user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname") mock_open.assert_called_once_with(expected_path, "w") def test_replace_writes_expected_content(self, mock_open): store = AlertCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) - user_path = path.expanduser("~/.code42cli") + user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname") mock_open.return_value.write.assert_called_once_with("123") def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): store = AlertCursorStore(PROFILE_NAME) store.delete("deleteme") - user_path = path.expanduser("~/.code42cli") + user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "deleteme") mock_remove.assert_called_once_with(expected_path) @@ -115,7 +115,7 @@ def test_get_returns_expected_timestamp(self, mock_open): def test_get_reads_expected_file(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) store.get(CURSOR_NAME) - user_path = path.expanduser("~/.code42cli") + user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join(user_path, "file_event_checkpoints", PROFILE_NAME, CURSOR_NAME) mock_open.assert_called_once_with(expected_path) @@ -129,7 +129,7 @@ def test_get_when_profile_does_not_exist_returns_none(self, mocker): def test_replace_writes_to_expected_file(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) - user_path = path.expanduser("~/.code42cli") + user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join( user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname" ) @@ -138,7 +138,7 @@ def test_replace_writes_to_expected_file(self, mock_open): def test_replace_writes_expected_content(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) - user_path = path.expanduser("~/.code42cli") + user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join( user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname" ) @@ -147,7 +147,7 @@ def test_replace_writes_expected_content(self, mock_open): def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): store = FileEventCursorStore(PROFILE_NAME) store.delete("deleteme") - user_path = path.expanduser("~/.code42cli") + user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join(user_path, "file_event_checkpoints", PROFILE_NAME, "deleteme") mock_remove.assert_called_once_with(expected_path) diff --git a/tests/test_date_helper.py b/tests/test_date_helper.py index ecbe2b8c3..98c5dab6a 100644 --- a/tests/test_date_helper.py +++ b/tests/test_date_helper.py @@ -1,5 +1,7 @@ from datetime import datetime +import click + from c42eventextractor.common import convert_datetime_to_timestamp from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp diff --git a/tests/test_invoker.py b/tests/test_invoker.py deleted file mode 100644 index 09cfcbd7c..000000000 --- a/tests/test_invoker.py +++ /dev/null @@ -1,180 +0,0 @@ -import pytest - -from requests.exceptions import HTTPError -from requests import Response, Request -import logging - -from py42.exceptions import Py42ForbiddenError - -from code42cli.main import MainSubcommandLoader -from code42cli.commands import Command, SubcommandLoader -from code42cli.errors import Code42CLIError -from code42cli.invoker import CommandInvoker -from code42cli.parser import ArgumentParserError, CommandParser - - -def dummy_method(one, two, three=None): - if three == "test": - return "success" - - -class SubcommandLoaderTop(SubcommandLoader): - def load_commands(self): - return [ - Command( - "testsub1", "the subdesc1", subcommand_loader=SubcommandLoaderBottom("testsub1") - ), - Command("testsub2", "the subdesc2"), - ] - - -class SubcommandLoaderBottom(SubcommandLoader): - def load_commands(self): - return [Command("inner1", "the innerdesc1", handler=dummy_method)] - - -@pytest.fixture -def mock_parser(mocker): - return mocker.MagicMock(spec=CommandParser) - - -class TestCommandInvoker(object): - def test_run_top_cmd(self, mock_parser): - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - invoker = CommandInvoker(cmd, mock_parser) - invoker.run([]) - mock_parser.prepare_cli_help.assert_called_once_with(cmd) - - def test_run_nested_cmd_calls_prepare_command(self, mock_parser): - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - invoker = CommandInvoker(cmd, mock_parser) - invoker.run(["testsub1", "inner1", "one", "two", "--three", "test"]) - subcommand = cmd.subcommands[0].subcommands[0] - mock_parser.prepare_command.assert_called_once_with(subcommand, ["testsub1", "inner1"]) - - def test_run_nested_cmd_calls_successfully(self, mocker, mock_parser): - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - parsed_args = mocker.MagicMock() - mock_parser.parse_args.return_value = parsed_args - invoker = CommandInvoker(cmd, mock_parser) - invoker.run(["testsub1", "inner1", "one", "two", "--three", "test"]) - assert parsed_args.func.call_count - - def test_run_nested_cmd_when_raises_argumentparsererror_prints_help(self, mocker, mock_parser): - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - mock_parser.parse_args.side_effect = ArgumentParserError() - mock_subparser = mocker.MagicMock() - mock_parser.prepare_command.return_value = mock_subparser - invoker = CommandInvoker(cmd, mock_parser) - with pytest.raises(SystemExit): - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - assert mock_subparser.print_help.call_count - - def test_run_when_errors_occur_from_handler_calls_logs_error(self, mocker, mock_parser, caplog): - ex = Exception("test") - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - mock_parser.parse_args.side_effect = ex - mock_subparser = mocker.MagicMock() - mock_parser.prepare_command.return_value = mock_subparser - invoker = CommandInvoker(cmd, mock_parser) - with caplog.at_level(logging.ERROR): - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - assert str(ex) in caplog.text - - def test_run_when_errors_occur_from_handler_calls_logs_command( - self, mocker, mock_parser, caplog - ): - ex = Exception("test") - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - mock_parser.parse_args.side_effect = ex - mock_subparser = mocker.MagicMock() - mock_parser.prepare_command.return_value = mock_subparser - invoker = CommandInvoker(cmd, mock_parser) - with caplog.at_level(logging.ERROR): - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - assert "code42 testsub1 inner1 one two --invalid test" in caplog.text - - def test_run_when_forbidden_error_occurs_logs_message(self, mocker, mock_parser, caplog): - http_error = mocker.MagicMock(spec=HTTPError) - http_error.response = mocker.MagicMock(spec=Response) - http_error.response.request = None - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) - mock_subparser = mocker.MagicMock() - mock_parser.prepare_command.return_value = mock_subparser - invoker = CommandInvoker(cmd, mock_parser) - - with caplog.at_level(logging.ERROR): - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - assert ( - u"You do not have the necessary permissions to perform this task. Try using or " - u"creating a different profile." in caplog.text - ) - - def test_run_when_forbidden_error_occurs_logs_command(self, mocker, mock_parser, caplog): - http_error = mocker.MagicMock(spec=HTTPError) - http_error.response = mocker.MagicMock(spec=Response) - http_error.response.request = None - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) - mock_subparser = mocker.MagicMock() - mock_parser.prepare_command.return_value = mock_subparser - invoker = CommandInvoker(cmd, mock_parser) - - with caplog.at_level(logging.ERROR): - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - assert "code42 testsub1 inner1 one two --invalid test" in caplog.text - - def test_run_when_forbidden_error_occurs_logs_request(self, mocker, mock_parser, caplog): - http_error = mocker.MagicMock(spec=HTTPError) - http_error.response = mocker.MagicMock(spec=Response) - request = mocker.MagicMock(spec=Request) - request.body = {"foo": "bar"} - http_error.response.request = request - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - mock_parser.parse_args.side_effect = Py42ForbiddenError(http_error) - mock_subparser = mocker.MagicMock() - mock_parser.prepare_command.return_value = mock_subparser - invoker = CommandInvoker(cmd, mock_parser) - - with caplog.at_level(logging.ERROR): - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - assert str(request.body) in caplog.text - - def test_run_when_cli_error_occurs_logs_request(self, mocker, mock_parser, caplog): - cmd = Command("", "top level desc", subcommand_loader=SubcommandLoaderTop("")) - mock_parser.parse_args.side_effect = Code42CLIError("a code42cli error") - mock_subparser = mocker.MagicMock() - mock_parser.prepare_command.return_value = mock_subparser - invoker = CommandInvoker(cmd, mock_parser) - - with caplog.at_level(logging.ERROR): - invoker.run(["testsub1", "inner1", "one", "two", "--invalid", "test"]) - assert "a code42cli error" in caplog.text - - def test_run_incorrect_command_suggests_proper_sub_commands(self, caplog): - command = Command(u"", u"", subcommand_loader=MainSubcommandLoader()) - cmd_invoker = CommandInvoker(command) - with pytest.raises(SystemExit): - cmd_invoker.run([u"profile", u"crate"]) - with caplog.at_level(logging.ERROR): - assert u"Did you mean one of the following?" in caplog.text - assert u"create" in caplog.text - - def test_run_incorrect_command_suggests_proper_main_commands(self, caplog): - command = Command(u"", u"", subcommand_loader=MainSubcommandLoader()) - cmd_invoker = CommandInvoker(command) - with pytest.raises(SystemExit): - cmd_invoker.run([u"prfile", u"crate"]) - with caplog.at_level(logging.ERROR): - assert u"Did you mean one of the following?" in caplog.text - assert u"profile" in caplog.text - - def test_run_incorrect_command_suggests_proper_argument_name(self, caplog): - command = Command(u"", u"", subcommand_loader=MainSubcommandLoader()) - cmd_invoker = CommandInvoker(command) - with pytest.raises(SystemExit): - cmd_invoker.run([u"security-data", u"write-to", u"abc", u"--filename"]) - with caplog.at_level(logging.ERROR): - assert u"Did you mean one of the following?" in caplog.text - assert u"--file-name" in caplog.text diff --git a/tests/test_logger.py b/tests/test_logger.py index 27c71cc03..77c777556 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -7,10 +7,7 @@ from code42cli.logger import ( add_handler_to_logger, logger_has_handlers, - get_view_exceptions_location_message, - RedStderrHandler, - InPlaceStreamHandler, - get_progress_logger, + get_view_error_details_message, CliLogger, ) from code42cli.util import get_user_project_path @@ -39,110 +36,27 @@ def test_logger_has_handlers_when_logger_does_not_have_handlers_returns_false(): def test_get_view_exceptions_location_message_returns_expected_message(): - actual = get_view_exceptions_location_message() + actual = get_view_error_details_message() path = os.path.join(get_user_project_path("log"), "code42_errors.log") - expected = u"View exceptions that occurred at {}.".format(path) + expected = u"View details in {}".format(path) assert actual == expected -class TestRedStderrHandler(object): - def test_emit_when_error_adds_red_text(self, mocker, caplog): - handler = RedStderrHandler() - record = mocker.MagicMock(spec=logging.LogRecord) - record.msg = "TEST" - record.levelno = logging.ERROR - - logger = mocker.patch("logging.StreamHandler.emit") - handler.emit(record) - actual = logger.call_args[0][0].msg - assert actual == "\x1b[91mERROR: TEST\x1b[0m" - - def test_emit_when_info_does_not_alter(self, mocker, caplog): - handler = RedStderrHandler() - record = mocker.MagicMock(spec=logging.LogRecord) - record.msg = "TEST" - record.levelno = logging.INFO - - logger = mocker.patch("logging.StreamHandler.emit") - handler.emit(record) - actual = logger.call_args[0][0].msg - assert actual == "TEST" - - -class TestInPlaceStreamHandler(object): - def test_emit_when_runtime_recursion_error_occurs_raises_error(self, mocker): - handler = InPlaceStreamHandler() - record = mocker.MagicMock(spec=logging.LogRecord) - - def side_effect(*args, **kwargs): - raise RuntimeError( - "maximum recursion depth exceeded while getting the str of an object" - ) - - handler.format = mocker.MagicMock() - handler.format = side_effect - with pytest.raises(RuntimeError): - handler.emit(record) - - def test_emit_when_non_recursion_error_occurs_calls_handle_error(self, mocker): - handler = InPlaceStreamHandler() - record = mocker.MagicMock(spec=logging.LogRecord) - spy = mocker.spy(handler, "handleError") - - def side_effect(*args, **kwargs): - raise Exception("Bad thing happened") - - handler.format = mocker.MagicMock() - handler.format = side_effect - try: - handler.emit(record) - except Exception: - spy.assert_called_once_with(record) - - class TestCliLogger(object): _logger = CliLogger() def test_init_creates_user_error_logger_with_expected_handlers(self, mocker): - is_interactive = mocker.patch("code42cli.logger.is_interactive") - is_interactive.return_value = True logger = CliLogger() - handler_types = [type(h) for h in logger._user_error_logger.handlers] - assert RedStderrHandler in handler_types + handler_types = [type(h) for h in logger._logger.handlers] assert RotatingFileHandler in handler_types - def test_print_info_logs_expected_text_at_expected_level(self, caplog): - with caplog.at_level(logging.INFO): - self._logger.print_info("TEST") - assert "TEST" in caplog.text - - def test_print_bold_logs_expected_text_at_expected_level(self, caplog): - with caplog.at_level(logging.INFO): - self._logger.print_bold("TEST") - assert "TEST" in caplog.text - - def test_print_and_log_error_logs_expected_text_at_expected_level(self, caplog): - with caplog.at_level(logging.ERROR): - self._logger.print_and_log_error("TEST") - assert "TEST" in caplog.text - - def test_print_and_log_info_logs_expected_text_at_expected_level(self, caplog): - with caplog.at_level(logging.INFO): - self._logger.print_and_log_info("TEST") - assert "TEST" in caplog.text - def test_log_error_logs_expected_text_at_expected_level(self, caplog): with caplog.at_level(logging.ERROR): ex = Exception("TEST") self._logger.log_error(ex) assert str(ex) in caplog.text - def test_print_errors_occurred_message_logs_expected_text_at_expected_level(self, caplog): - with caplog.at_level(logging.ERROR): - self._logger.print_errors_occurred_message() - assert "View exceptions that occurred at" in caplog.text - def test_log_verbose_error_logs_expected_text_at_expected_level(self, mocker, caplog): with caplog.at_level(logging.ERROR): request = mocker.MagicMock(sepc=Request) diff --git a/tests/cmds/search_shared/test_logger_factory.py b/tests/test_logger_factory.py similarity index 98% rename from tests/cmds/search_shared/test_logger_factory.py rename to tests/test_logger_factory.py index 44de59269..334b17f13 100644 --- a/tests/cmds/search_shared/test_logger_factory.py +++ b/tests/test_logger_factory.py @@ -7,7 +7,7 @@ FileEventDictToRawJSONFormatter, ) -import code42cli.cmds.search_shared.logger_factory as factory +import code42cli.cmds.search.logger_factory as factory @pytest.fixture diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 3d5400d38..000000000 --- a/tests/test_main.py +++ /dev/null @@ -1,75 +0,0 @@ -from code42cli.main import main, MainSubcommandLoader - - -class TestMainSubcommandLoader(object): - def test_getitem_returns_top_level_subcommand_names(self): - loader = MainSubcommandLoader() - assert "alerts" in loader.names - assert "alert-rules" in loader.names - assert "departing-employee" in loader.names - - def test_getitem_when_at_alert_level_returns_alerts_subcommand_names(self): - loader = MainSubcommandLoader() - subloader = loader[loader.ALERTS].names - assert "print" in subloader - assert "write-to" in subloader - assert "clear-checkpoint" in subloader - - def test_getitem_returns_flagged_arg_names_when_is_leaf_command(self): - loader = MainSubcommandLoader() - args = loader[loader.ALERTS][u"print"] - assert "--use-checkpoint" in args - assert "--actor" in args - - def test_getitem_returns_choices_when_is_choice_based_arg(self): - loader = MainSubcommandLoader() - args = loader[loader.SECURITY_DATA][u"send-to"][u"127.0.0.1"]["-p"] - assert "UDP" in args - - -# run the help commands on some stuff to prove stuff loads - - -def _execute_test(capsys, assert_command, assert_value=False): - try: - main() - except SystemExit: - assert_value = True - capture = capsys.readouterr() - assert assert_command in capture.out - assert assert_value - - -def test_securitydata_commands_load(capsys, mocker): - mocker.patch("sys.argv", [u"code42", u"security-data", u"print", u"-h"]) - _execute_test(capsys, u"print") - - -def test_alerts_commands_load(capsys, mocker): - mocker.patch("sys.argv", [u"code42", u"alerts", u"print", u"-h"]) - _execute_test(capsys, u"print") - - -def test_profile_commands_load(capsys, mocker): - mocker.patch("sys.argv", [u"code42", u"profile", u"show", u"-h"]) - _execute_test(capsys, u"show") - - -def test_departing_employee_commands_load(capsys, mocker): - mocker.patch("sys.argv", [u"code42", u"departing-employee", u"add", u"-h"]) - _execute_test(capsys, u"add") - - -def test_high_risk_employee_commands_load(capsys, mocker): - mocker.patch("sys.argv", [u"code42", u"high-risk-employee", u"bulk", u"-h"]) - _execute_test(capsys, u"bulk") - - -def test_alert_rules_commands_load(capsys, mocker): - mocker.patch("sys.argv", [u"code42", u"alert-rules", u"bulk", u"add", u"-h"]) - _execute_test(capsys, u"add") - - -def test_legal_hold_commands_load(capsys, mocker): - mocker.patch("sys.argv", [u"code42", u"legal-hold", u"bulk", u"add", u"-h"]) - _execute_test(capsys, u"bulk") diff --git a/tests/test_parser.py b/tests/test_parser.py deleted file mode 100644 index 45f41fc90..000000000 --- a/tests/test_parser.py +++ /dev/null @@ -1,180 +0,0 @@ -import pytest - -from code42cli.commands import Command, SubcommandLoader -from code42cli.parser import ( - ArgumentParserError, - CommandParser, - exit_if_mutually_exclusive_args_used_together, -) - -import code42cli.cmds.search_shared.enums as enums - - -def dummy_method(): - return "success" - - -def dummy_method_required_args(one, two): - return "success" - - -def dummy_method_optional_args(one=None, two=None): - if one == "onetest" and two == "twotest": - return "success" - - -class MockSubcommandLoader(SubcommandLoader): - def load_commands(self): - return [Command("testsub1", "the subdesc1"), Command("testsub2", "the subdesc2")] - - -class TestCommandParser(object): - def test_prepare_command_when_no_args(self): - cmd = Command("runnable", "the desc", "the usage", handler=dummy_method) - parts = ["runnable"] - parser = CommandParser() - parser.prepare_command(cmd, parts) - parsed_args = parser.parse_args(["runnable"]) - assert parsed_args.func(parsed_args) == "success" - - def test_prepare_command_when_no_args_and_nested(self): - cmd = Command("runnable", "the desc", "the usage", handler=dummy_method) - parts = ["subgroup", "runnable"] - parser = CommandParser() - parser.prepare_command(cmd, parts) - parsed_args = parser.parse_args(["subgroup", "runnable"]) - assert parsed_args.func(parsed_args) == "success" - - def test_prepare_command_when_required_args(self): - cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_required_args) - parts = ["runnable"] - parser = CommandParser() - parser.prepare_command(cmd, parts) - parsed_args = parser.parse_args(["runnable", "--one", "onetest", "--two", "twotest"]) - assert parsed_args.func(parsed_args) == "success" - - def test_prepare_command_when_required_args_help_outputs_help(self, capsys): - cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_required_args) - parts = ["runnable"] - parser = CommandParser() - parser.prepare_command(cmd, parts) - success = False - try: - parser.parse_args(["runnable", "-h"]) - except SystemExit: - success = True - captured = capsys.readouterr() - assert "the desc" in captured.out - assert "one" in captured.out - assert "two" in captured.out - assert success - - def test_prepare_command_when_optional_args(self): - cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_optional_args) - parts = ["runnable"] - parser = CommandParser() - parser.prepare_command(cmd, parts) - parsed_args = parser.parse_args(["runnable", "--one", "onetest", "--two", "twotest"]) - assert parsed_args.func(parsed_args) == "success" - - def test_prepare_command_when_optional_args_help_outputs_help(self, capsys): - cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_optional_args) - parts = ["runnable"] - parser = CommandParser() - parser.prepare_command(cmd, parts) - success = False - try: - parsed_args = parser.parse_args(["runnable", "-h"]) - except SystemExit: - success = True - captured = capsys.readouterr() - assert "the desc" in captured.out - assert "--one" in captured.out - assert "--two" in captured.out - assert success - - def test_prepare_command_when_required_args_and_args_missing_throws(self, capsys): - cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_required_args) - parts = ["runnable"] - parser = CommandParser() - parser.prepare_command(cmd, parts) - with pytest.raises(ArgumentParserError): - parsed_args = parser.parse_args(["runnable"]) - - def test_prepare_command_when_extra_args_throws(self): - cmd = Command("runnable", "the desc", "the usage", handler=dummy_method_optional_args) - parts = ["runnable"] - parser = CommandParser() - parser.prepare_command(cmd, parts) - with pytest.raises(ArgumentParserError): - parsed_args = parser.parse_args(["runnable", "--invalid"]) - - def test_prepare_cli_help_outputs_group_info(self, capsys): - cmd = Command( - "runnable", "the desc", "the usage", subcommand_loader=MockSubcommandLoader("runnable") - ) - parser = CommandParser() - parser.prepare_cli_help(cmd) - parser.parse_args([]) - parser.print_help() - captured = capsys.readouterr() - assert "the subdesc1" in captured.out - assert "testsub1" in captured.out - assert "the subdesc2" in captured.out - assert "testsub2" in captured.out - - -@pytest.mark.parametrize( - "arg", - [ - "c42_username", - "actor", - "md5", - "sha256", - "source", - "file_name", - "file_path", - "process_owner", - "tab_url", - ], -) -def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_other_incompatible_multi_narg_argument_passed( - file_event_namespace, arg -): - file_event_namespace.advanced_query = "some complex json" - setattr(file_event_namespace, arg, ["test_value"]) - with pytest.raises(SystemExit): - exit_if_mutually_exclusive_args_used_together( - file_event_namespace, list(enums.FileEventFilterArguments()) - ) - - -def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_uses_checkpoint_does_not_exit_as_invalid_args_does_not_contain_checkpoint( - alert_namespace -): - alert_namespace.advanced_query = "some complex json" - alert_namespace.use_checkpoint = "foo" - exit_if_mutually_exclusive_args_used_together( - alert_namespace, list(enums.AlertFilterArguments()) - ) - - -def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_include_non_exposure_exits( - file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.include_non_exposure = True - with pytest.raises(SystemExit): - exit_if_mutually_exclusive_args_used_together( - file_event_namespace, list(enums.FileEventFilterArguments()) - ) - - -def test_exit_if_advanced_query_used_with_other_search_args_when_is_advanced_query_and_has_format_does_not_exit( - file_event_namespace -): - file_event_namespace.advanced_query = "some complex json" - file_event_namespace.format = "JSON" - exit_if_mutually_exclusive_args_used_together( - file_event_namespace, list(enums.FileEventFilterArguments()) - ) diff --git a/tests/test_profile.py b/tests/test_profile.py index 38a6344c1..9f7362bc5 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,11 +1,15 @@ import pytest import logging +from click.testing import CliRunner +from code42cli.main import cli + import code42cli.profile as cliprofile from code42cli import PRODUCT_NAME -from code42cli.cmds.search_shared.cursor_store import FileEventCursorStore, AlertCursorStore +from code42cli.cmds.search.cursor_store import FileEventCursorStore, AlertCursorStore from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection, create_mock_profile +from code42cli.errors import Code42CLIError @pytest.fixture @@ -67,9 +71,9 @@ def test_get_profile_returns_expected_profile(config_accessor): assert profile.name == "testprofilename" -def test_get_profile_when_config_accessor_throws_exits(config_accessor): +def test_get_profile_when_config_accessor_raises_cli_error(config_accessor): config_accessor.get_profile.side_effect = NoConfigProfileError() - with pytest.raises(SystemExit): + with pytest.raises(Code42CLIError): cliprofile.get_profile("testprofilename") @@ -90,7 +94,7 @@ def test_validate_default_profile_prints_set_default_help_when_no_valid_default_ ): config_accessor.get_profile.side_effect = NoConfigProfileError() config_accessor.get_all_profiles.return_value = [MockSection("thisprofilexists")] - with pytest.raises(SystemExit): + with pytest.raises(Code42CLIError): cliprofile.validate_default_profile() capture = capsys.readouterr() assert "No default profile set." in capture.out @@ -101,7 +105,7 @@ def test_validate_default_profile_prints_create_profile_help_when_no_valid_defau ): config_accessor.get_profile.side_effect = NoConfigProfileError() config_accessor.get_all_profiles.return_value = [] - with pytest.raises(SystemExit): + with pytest.raises(Code42CLIError): cliprofile.validate_default_profile() capture = capsys.readouterr() assert "No existing profile." in capture.out @@ -137,16 +141,10 @@ def test_create_profile_uses_expected_profile_values(config_accessor): ) -def test_create_profile_if_profile_exists_exits(mocker, caplog, config_accessor): +def test_create_profile_if_profile_exists_exits(mocker, cli_state, caplog, config_accessor): config_accessor.get_profile.return_value = mocker.MagicMock() - success = True - with caplog.at_level(logging.ERROR): - try: - cliprofile.create_profile("foo", "bar", "baz", True) - except SystemExit: - success = True - assert "already exists" in caplog.text - assert success + with pytest.raises(Code42CLIError): + cliprofile.create_profile("foo", "bar", "baz", True) def test_get_all_profiles_returns_expected_profile_list(config_accessor): diff --git a/tests/test_progress_bar.py b/tests/test_progress_bar.py deleted file mode 100644 index e2e86feee..000000000 --- a/tests/test_progress_bar.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- - -import logging - -from code42cli.progress_bar import ProgressBar - - -class TestProgressBar(object): - def test_update_when_zero_processed_logs_zero_blocks(self, caplog): - bar = ProgressBar(100) - bar.update(0, "MESSAGE") - with caplog.at_level(logging.INFO): - assert u"█" not in caplog.text - assert "MESSAGE" in caplog.text - - def test_update_logs_one_block_per_processed(self, caplog): - bar = ProgressBar(100) - bar.update(50, "MESSAGE") - with caplog.at_level(logging.INFO): - assert u"█" * 50 in caplog.text - assert u"█" * 51 not in caplog.text - assert "MESSAGE" in caplog.text - - def test_clear_bar_and_print_result_clears_progress_bar(self, caplog): - bar = ProgressBar(100) - bar.clear_bar_and_print_final("MESSAGE") - with caplog.at_level(logging.INFO): - assert u"█" not in caplog.text - assert "MESSAGE" in caplog.text diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 347698b24..e897a156e 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,53 +1,97 @@ import py42.sdk import py42.settings.debug as debug import pytest +from py42.exceptions import Py42UnauthorizedError +from requests import Response +from requests.exceptions import ConnectionError, RequestException +from code42cli.errors import Code42CLIError, LoggedCLIError from code42cli.sdk_client import create_sdk, validate_connection from .conftest import create_mock_profile +@pytest.fixture +def sdk_logger(mocker): + return mocker.patch("code42cli.sdk_client.logger") + + @pytest.fixture def mock_sdk_factory(mocker): return mocker.patch("py42.sdk.from_local_account") @pytest.fixture -def error_sdk_factory(mock_sdk_factory): - def side_effect(): - raise Exception() +def requests_exception(mocker): + mock_response = mocker.MagicMock(spec=Response) + mock_exception = mocker.MagicMock(spec=RequestException) + mock_exception.response = mock_response + return mock_exception - mock_sdk_factory.side_effect = side_effect - return mock_sdk_factory +def test_create_sdk_when_py42_exception_occurs_raises_and_logs_cli_error( + sdk_logger, mock_sdk_factory, requests_exception +): -def test_create_sdk_when_py42_exception_occurs_causes_exit(error_sdk_factory): + mock_sdk_factory.side_effect = Py42UnauthorizedError(requests_exception) profile = create_mock_profile() def mock_get_password(): return "Test Password" profile.get_password = mock_get_password - with pytest.raises(SystemExit): + with pytest.raises(Code42CLIError) as err: create_sdk(profile, False) + assert "Invalid credentials for user" in err.value.message + assert sdk_logger.log_error.call_count == 1 + assert "Failure in HTTP call" in sdk_logger.log_error.call_args[0][0] -def test_create_sdk_when_told_to_debug_turns_on_debug(mock_sdk_factory): + +def test_create_sdk_when_connection_exception_occurs_raises_and_logs_cli_error( + sdk_logger, mock_sdk_factory +): + mock_sdk_factory.side_effect = ConnectionError("connection message") profile = create_mock_profile() def mock_get_password(): return "Test Password" profile.get_password = mock_get_password - create_sdk(profile, True) - assert py42.settings.debug.level == debug.DEBUG + with pytest.raises(LoggedCLIError) as err: + create_sdk(profile, False) + + assert "Problem connecting to" in err.value.message + assert sdk_logger.log_error.call_count == 1 + assert "connection message" in sdk_logger.log_error.call_args[0][0] + + +def test_create_sdk_when_unknown_exception_occurs_raises_and_logs_cli_error( + sdk_logger, mock_sdk_factory +): + mock_sdk_factory.side_effect = Exception("test message") + profile = create_mock_profile() + + def mock_get_password(): + return "Test Password" + profile.get_password = mock_get_password + with pytest.raises(LoggedCLIError) as err: + create_sdk(profile, False) -def test_validate_connection_when_creating_sdk_raises_returns_false(error_sdk_factory): - assert not validate_connection("Test", "Password", "Authority") + assert "Unknown problem validating" in err.value.message + assert sdk_logger.log_error.call_count == 1 + assert "test message" in sdk_logger.log_error.call_args[0][0] -def test_validate_connection_when_sdk_does_not_raise_returns_true(mock_sdk_factory): - assert validate_connection("Test", "Password", "Authority") +def test_create_sdk_when_told_to_debug_turns_on_debug(mock_sdk_factory): + profile = create_mock_profile() + + def mock_get_password(): + return "Test Password" + + profile.get_password = mock_get_password + create_sdk(profile, True) + assert py42.settings.debug.level == debug.DEBUG def test_validate_connection_uses_given_credentials(mock_sdk_factory): diff --git a/tests/test_tree_nodes.py b/tests/test_tree_nodes.py deleted file mode 100644 index ab6e0750a..000000000 --- a/tests/test_tree_nodes.py +++ /dev/null @@ -1,63 +0,0 @@ -from code42cli.tree_nodes import SubcommandNode, ArgNode -from code42cli.commands import Command -from code42cli.args import ArgConfig - -from .conftest import DummySubcommandLoader, func_single_positional_arg_many_optional_args - - -class TestSubcommandNode(object): - def test_names_returns_names_of_commands(self): - node = SubcommandNode("code42", [Command("foo", ""), Command("bar", "")]) - assert len(node.names) == 2 - assert "foo" in node.names - assert "bar" in node.names - - def test_getitem_when_item_is_subcommand_returns_its_node_with_expected_names(self): - loader = DummySubcommandLoader("test") - command = Command("test", "", subcommand_loader=loader) - node = SubcommandNode("code42", [Command("foo", ""), command]) - actual = node["test"].names - # values found in TestSubcommandLoader - assert "sub1" in actual - assert "sub2" in actual - assert "sub3" in actual - - def test_getitem_when_item_is_arg_node_returns_flagged_based_args(self): - command = Command("test", "", handler=func_single_positional_arg_many_optional_args) - node = SubcommandNode("code42", [Command("foo", ""), command]) - actual = node["test"].names - # values found in func_single_positional_arg_many_optional_args - assert "--two" in actual - assert "--three" in actual - assert "--four" in actual - - def test_getitem_when_item_is_arg_with_choices_returns_node_with_choices_for_names(self): - choices = ["something", "another"] - arg_config = ArgConfig("--two") - arg_config.set_choices(choices) - - def _customize_arg(argument_collection): - argument_collection.arg_configs["two"] = arg_config - - command = Command( - "test", - "", - handler=func_single_positional_arg_many_optional_args, - arg_customizer=_customize_arg, - ) - node = SubcommandNode("code42", [Command("foo", ""), command]) - test = node["test"] - actual = test["--two"].names - assert choices == actual - - -class TestArgNode(object): - def test_getitem_when_an_arg_has_choices_returns_choices_node(self): - arg1 = ArgConfig("-t") - arg2 = ArgConfig("-p") - choices = ["choice1, choice2, choice3"] - arg2.set_choices(choices) - - node = ArgNode({"arg1": arg1, "arg2": arg2}) - actual = node["-p"].names - assert choices == actual diff --git a/tests/test_util.py b/tests/test_util.py index 753eacc0d..e4b321077 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,9 +1,7 @@ import pytest from code42cli import PRODUCT_NAME -from code42cli.util import does_user_agree, get_url_parts, find_format_width, get_user_id -from code42cli.errors import UserDoesNotExistError - +from code42cli.util import does_user_agree, get_url_parts, find_format_width TEST_HEADER = {u"key1": u"Column 1", u"key2": u"Column 10", u"key3": u"Column 100"} @@ -11,11 +9,6 @@ _NAMESPACE = "{}.util".format(PRODUCT_NAME) -@pytest.fixture -def mock_input(mocker): - return mocker.patch("{}.get_input".format(_NAMESPACE)) - - def test_get_url_parts_when_given_host_and_port_returns_expected_parts(): url_str = "www.example.com:123" parts = get_url_parts(url_str) @@ -28,18 +21,18 @@ def test_get_url_parts_when_given_host_without_port_returns_expected_parts(): assert parts == ("www.example.com", None) -def test_does_user_agree_when_user_says_y_returns_true(mock_input): - mock_input.return_value = "y" +def test_does_user_agree_when_user_says_y_returns_true(mocker): + mocker.patch("builtins.input", return_value="y") assert does_user_agree("Test Prompt") -def test_does_user_agree_when_user_says_capital_y_returns_true(mock_input): - mock_input.return_value = "Y" +def test_does_user_agree_when_user_says_capital_y_returns_true(mocker): + mocker.patch("builtins.input", return_value="Y") assert does_user_agree("Test Prompt") -def test_does_user_agree_when_user_says_n_returns_false(mock_input): - mock_input.return_value = "n" +def test_does_user_agree_when_user_says_n_returns_false(mocker): + mocker.patch("builtins.input", return_value="n") assert not does_user_agree("Test Prompt") @@ -70,8 +63,3 @@ def test_find_format_width_filters_keys_not_present_in_header(): result, _ = find_format_width(report, header_with_subset_keys) for item in result: assert u"key2" not in item.keys() - - -def test_get_user_id_when_user_does_not_raise_error(sdk_without_user): - with pytest.raises(UserDoesNotExistError): - get_user_id(sdk_without_user, "risky employee") From 66d42cc503c8d7adf4ca026571f077b461f05f75 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 10 Jul 2020 15:52:42 -0500 Subject: [PATCH 086/349] Feature/remove write and send to (#108) * remove `write-to` and `send-to` and rename `print` to `search` on alerts/security-data cmds * remove unused loggers * update tests * fix case where exception is printed on KeyboardInterrupt and closed pipe. * update readme and changelog * add TCP examples * remove tests for removed func * remove tests for removed loggers * fix bug from change in latest py42 * add note about external tools for more complex requirements * updated docs --- CHANGELOG.md | 13 +- README.md | 114 ++++-- docs/commands/alerts.md | 41 +- docs/commands/securitydata.md | 45 +-- docs/index.md | 2 +- docs/userguides/profile.md | 2 +- docs/userguides/siemexample.md | 12 +- src/code42cli/cmds/alerts.py | 73 +--- src/code42cli/cmds/legal_hold.py | 3 +- src/code42cli/cmds/search/logger_factory.py | 49 --- src/code42cli/cmds/search/options.py | 19 - src/code42cli/cmds/securitydata.py | 113 +----- src/code42cli/logger.py | 2 +- src/code42cli/main.py | 2 +- src/code42cli/util.py | 13 - tests/cmds/conftest.py | 14 - tests/cmds/test_alerts.py | 371 ++++++------------ tests/cmds/test_securitydata.py | 393 +++++++------------- tests/test_logger_factory.py | 112 ------ tests/test_util.py | 14 +- 20 files changed, 375 insertions(+), 1032 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b95769497..6f258b608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,16 +13,23 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Changed - `-i` (`--incremental`) has been removed, use `-c` (`--use-checkpoint`) with a string name for the checkpoint instead. + - The code42cli has been migrated to the [click](https://click.palletsprojects.com) framework. This brings: - - BREAKING CHANGE: Commands that accept multiple values for the same option now must have the option flag provided - before each value: - `--option value1 --option value2` instead of `--option value1 value2` (which was previously possible). + - BREAKING CHANGE: Commands that accept multiple values for the same option now must have the option flag provided before each value: + use `--option value1 --option value2` instead of `--option value1 value2` (which was previously possible). - Cosmetic changes to error messages, progress bars, and help message formatting. +- The `print` command on the `security-data` and `alerts` command groups has been replaced with the `search` command. + This was a name change only, all other functionality remains the same. + ### Added - Profile can now save multiple alert and file event checkpoints. The name of the checkpoint to be used for a given query should be passed to `-c` (`--use-checkpoint`). +### Removed + +- The `write-to` and `send-to` commands on `security-data` and `alerts` command groups. + ## 0.7.3 - 2020-06-23 ### Fixed diff --git a/README.md b/README.md index 57ae9daea..3bbe0efdf 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ When the `--profile` flag is available on other commands, such as those in `secu instead of the default one. For example, ```bash -code42 security-data print -b 2020-02-02 --profile MY_SECOND_PROFILE +code42 security-data search -b 2020-02-02 --profile MY_SECOND_PROFILE ``` To see all your profiles, do: @@ -63,11 +63,10 @@ code42 profile list ## Security Data and Alerts -Using the CLI, you can query for security events and alerts and send them to three possible destination types: +Using the CLI, you can query for security events and alerts just like in the admin console, but the results are output +to stdout so they can be written to a file or piped out to another process (for sending to an external syslog server, for +example). -* stdout -* A file -* A server, such as SysLog The following examples pertain to security events, but can also be used for alerts by replacing `security-data` with `alerts`: @@ -75,7 +74,7 @@ The following examples pertain to security events, but can also be used for aler To print events to stdout, do: ```bash -code42 security-data print -b +code42 security-data search -b ``` Note that `-b` or `--begin` is usually required. @@ -85,79 +84,110 @@ And end date can also be given with `-e` or `--end` to query for a specific date To specify a begin/end time, you can pass a date or a date w/ time as a string: ```bash -code42 security-data print -b '2020-02-02 12:51:00' +code42 security-data search -b '2020-02-02 12:51:00' ``` ```bash -code42 security-data print -b '2020-02-02 12:30' +code42 security-data search -b '2020-02-02 12:30' ``` ```bash -code42 security-data print -b '2020-02-02 12' +code42 security-data search -b '2020-02-02 12' ``` ```bash -code42 security-data print -b 2020-02-02 +code42 security-data search -b 2020-02-02 ``` or a shorthand string specifying either days, hours, or minutes back from the current time: ```bash -code42 security-data print -b 30d +code42 security-data search -b 30d ``` ```bash -code42 security-data print -b 10d -e 12h +code42 security-data search -b 10d -e 12h ``` -Begin date will be ignored if provided on subsequent queries using `-i`. +Begin date will be ignored if provided on subsequent queries using `-c/--use-checkpoint`. Use different format with `-f`: ```bash -code42 security-data print -b 2020-02-02 -f CEF +code42 security-data search -b 2020-02-02 -f CEF ``` The available formats are CEF, JSON, and RAW-JSON. Currently, CEF format is only supported for security events. -To write events to a file, do: +To write events to a file, just redirect your output: ```bash -code42 security-data write-to filename.txt -b 2020-02-02 +code42 security-data search -b 2020-02-02 > filename.txt ``` -To send events to a server, do: +To send events to an external server using `netcat` on Linux/Mac: +UDP: ```bash -code42 security-data send-to syslog.company.com -p TCP -b 2020-02-02 +code42 security-data search -b 10d | nc -u syslog.company.com 514 ``` -To only get events that Code42 previously did not observe since you last recorded a checkpoint, use the `-i` flag. - +TCP: ```bash -code42 security-data send-to syslog.company.com -i +code42 security-data search -b 10d | nc server.company.com 8080 +``` + +Using `powershell` on Windows: + +UDP: +```powershell +# set up connection +$Connection = New-Object System.Net.Sockets.UDPClient("syslog.company.com",514) + +# pipe code42 output through connection +code42 security-data search -b 10d | foreach {$Message = [Text.Encoding]::UTF8.GetBytes($_); $Connection.Send($Message, $Message.Length)} +``` + +TCP: +```powershell +# set up connection +$Connection = New-Object System.Net.Sockets.TcpClient("127.0.0.1","65432") +$Writer = New-Object System.IO.StreamWriter($Connection.GetStream()) + +# pipe code42 output through connection +code42 security-data search -b 10d | foreach { $Writer.WriteLine($_); $Writer.Flush() } ``` -This is only guaranteed if you did not change your query. +Note: For more complex requirements when sending to an external server (SSL, special formatting, etc.), use a dedicated +syslog forwarding tool like `rsyslog` or connection tunneling tool like `stunnel`. + +If you want to periodically run the same query, but only retrieve the new events each time, use the +`-c/--use-checkpoint` option with a name for your checkpoint. This stores the timestamp of the query's last event to a +file on disk and uses that as the "begin date" timestamp filter on the next query that uses the same checkpoint name. +Checkpoints are stored per profile. -To send events to a server using a specific profile, do: +Initial run requires a begin date: +```bash +code42 security-data search -b 30d --use-checkpoint my_checkpoint +``` +Subsequent runs do not: ```bash -code42 security-data --profile PROFILE_FOR_RECURRING_JOB send-to syslog.company.com -b 2020-02-02 -f CEF -i +code42 security-data search --use-checkpoint my_checkpoint ``` You can also use wildcard for queries, but note, if they are not in quotes, you may get unexpected behavior. ```bash -code42 security-data print --actor "*" +code42 security-data search --actor "*" ``` -Each destination-type subcommand shares query parameters +The search query parameters are as follows: -- `-t` (exposure types) -- `-b` (begin date) -- `-e` (end date) +- `-t/--type` (exposure types) +- `-b/--begin` (begin date) +- `-e/--end` (end date) - `--c42-username` - `--actor` - `--md5` @@ -171,9 +201,29 @@ Each destination-type subcommand shares query parameters - `--advanced-query` (raw JSON query) You cannot use other query parameters if you use `--advanced-query`. -To learn more about acceptable arguments, add the `-h` flag to `code42` or any of the destination-type subcommands. +To learn more about acceptable arguments, add the `-h` flag to `code42 security-data` + +Saved Searches: +The CLI can also access "saved searches" that are stored in the admin console, and run them via their saved search ID. +Use the `saved-search list` subcommand to list existing searches with their IDs: + +```bash +code42 security-data saved-search list +``` + +The `show` subcommand will give details about the search with the provided ID: + +```bash +code42 security-data saved-search show +``` + +To get the results of a saved search, use the `--saved-search` option with your search ID on the `search` subcommand: + +```bash +code42 security-data search --saved-search +``` ## Detection Lists @@ -212,7 +262,9 @@ reported. If you keep getting prompted for your password, try resetting with `code42 profile reset-pw`. If that doesn't work, delete your credentials file located at ~/.code42cli or the entry in keychain. -## Tab completion +## Shell tab completion + +To enable shell autocomplete when you hit `tab` after the first few characters of a command name, do the following: For Bash, add this to ~/.bashrc: diff --git a/docs/commands/alerts.md b/docs/commands/alerts.md index 5d84a91f1..7ce9408d7 100644 --- a/docs/commands/alerts.md +++ b/docs/commands/alerts.md @@ -1,9 +1,10 @@ # Alerts -## Shared arguments +## search -Search args are shared between `print`, `write-to`, and `send-to` commands. +Search for alerts and print them to stdout. +Arguments: * `advanced-query`: A raw JSON alerts query. Useful for when the provided query parameters do not satisfy your requirements. WARNING: Using advanced queries is incompatible with other query-building args. * `-b`, `--begin`: The beginning of the date range in which to look for alerts, can be a date/time in yyyy-MM-dd (UTC) @@ -32,43 +33,9 @@ Search args are shared between `print`, `write-to`, and `send-to` commands. * `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. * `-c`, `--use-checkpoint` (optional): Get only file events that were not previously retrieved by writing the timestamp of the last event retrieved to a named checkpoint. -## print - -Print file events to stdout. - -Arguments: -* search args (note that begin date is often required). - -Usage: -```bash -code42 alerts print -b -``` - -## write-to - -Write file events to the file with the given name. - -Arguments: -* `output_file`: The name of the local file to send output to. -* search args (note that begin date is often required). - -Usage: -```bash -code42 alerts write-to -b 2020-03-01 -``` - -## send-to - -Send file events to the given server address. - -Arguments: -* `server`: The server address to send output to. -* `protocol` (optional): Protocol used to send logs to server. Available choices= [TCP, UDP]. -* search args (note that begin date is often required). - Usage: ```bash -code42 alerts send-to +code42 alerts search -b ``` ## clear-checkpoint diff --git a/docs/commands/securitydata.md b/docs/commands/securitydata.md index 90737e2ea..996ba3f1f 100644 --- a/docs/commands/securitydata.md +++ b/docs/commands/securitydata.md @@ -1,11 +1,14 @@ # Security Data -## Shared arguments -Search args are shared between `print`, `write-to`, and `send-to` commands. +## search -* `--advanced-query` (optional): A raw JSON file events query. Useful for when the provided query parameters do not +Search for file events and print them to stdout. + +Arguments: +* `--advanced-query` (optional | cannot be used with other query options): A raw JSON file events query. Useful for when the provided query parameters do not satisfy your requirements. WARNING: Using advanced queries is incompatible with other query-building args. +* `--saved-search` (optional | cannot be used with other query options): Get events from a saved search filter (created in the Code42 admin console) with the given ID. * `-b`, `--begin` (required except for non-first runs in checkpoint mode): The beginning of the date range in which to look for file events, can be a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') or a short value @@ -28,45 +31,11 @@ Search args are shared between `print`, `write-to`, and `send-to` commands. * `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. * `-c`, `--use-checkpoint` (optional): Get only file events that were not previously retrieved by writing the timestamp of the last event retrieved to a named checkpoint. - -## print - -Print file events to stdout. - -Arguments: -* search args (note that begin date is often required). - -Usage: -```bash -code42 security-data print -b -``` - -## write-to - -Write file events to the file with the given name. - -Arguments: -* `output_file`: The name of the local file to send output to. -* search args (note that begin date is often required). - Usage: ```bash -code42 security-data write-to -b 2020-03-01 +code42 security-data search -b ``` -## send-to - -Send file events to the given server address. - -Arguments: -* `server`: The server address to send output to. -* `protocol` (optional): Protocol used to send logs to server. Available choices= [TCP, UDP]. -* search args (note that begin date is often required). - -Usage: -```bash -code42 security-data send-to -``` ## clear-checkpoint diff --git a/docs/index.md b/docs/index.md index 8a4da81a6..6e8819395 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ To use the Code42 CLI, you must have: A Code42 Diamond or Platinum product plan Endpoint monitoring enabled in the Code42 console -Python version 2.7.x, or 3.5 and later installed +Python version 3.5 and later installed ## Content diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md index eca2d0b30..e7458e822 100644 --- a/docs/userguides/profile.md +++ b/docs/userguides/profile.md @@ -25,7 +25,7 @@ When the `--profile` flag is available on other commands, such as those in `secu instead of the default one. For example, ```bash -code42 security-data print -b 2020-02-02 --profile MY_SECOND_PROFILE +code42 security-data search -b 2020-02-02 --profile MY_SECOND_PROFILE ``` To see all your profiles, do: diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index 2cffa9e97..bcfd76add 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -25,14 +25,14 @@ scheduled job or run ad-hoc queries. Learn more about [searching](../commands/se ## Run a query as a scheduled job Use your favorite scheduling tool, such as cron or Windows Task Scheduler, to run a query on a regular basis. Specify -the profile to use by including `--profile`. For example: +the profile to use by including `--profile`. An example using `netcat` to forward results to an external syslog server: ```bash -code42 security-data send-to "https://syslog.example.com:514" -p TCP --profile profile1 -i +code42 security-data search --profile profile1 -c syslog_sender | nc syslog.example.com 514 ``` Note that it is best practice to use a separate profile when executing a scheduled task. This way, it is harder to -accidentally mess up your stored checkpoints by running `--use-checkpoint` adhoc queries. +accidentally mess up your stored checkpoints by running `--use-checkpoint` in adhoc queries. This query will send to the syslog server only the new security event data since the previous request. @@ -43,18 +43,18 @@ Examples of ad-hoc queries you can run are as follows. Print security data since March 5 for a user in raw JSON format: ```bash -code42 security-data print -f RAW-JSON -b 2020-03-05 --c42-username 'sean.cassidy@example.com' +code42 security-data search -f RAW-JSON -b 2020-03-05 --c42-username 'sean.cassidy@example.com' ``` Print security events since March 5 where a file was synced to a cloud service: ```bash -code42 security-data print -t CloudStorage -b 2020-03-05 +code42 security-data search -t CloudStorage -b 2020-03-05 ``` Write to a text file security events in raw JSON format where a file was read by browser or other app for a user since March 5: ```bash -code42 security-data write-to /Users/sangita.maskey/Downloads/c42cli_output.txt -f RAW-JSON -b 2020-03-05 -t ApplicationRead --c42-username 'sean.cassidy@example.com' +code42 security-data search -f RAW-JSON -b 2020-03-05 -t ApplicationRead --c42-username 'sean.cassidy@example.com' > /Users/sangita.maskey/Downloads/c42cli_output.txt ``` Example output for a single exposure event (in default JSON format): diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index e71c73816..ec79f8f94 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -23,8 +23,6 @@ contains_filter, not_contains_filter, not_in_filter, - output_file_arg, - server_options, ) from code42cli.options import sdk_options, OrderedGroup @@ -169,79 +167,22 @@ def clear_checkpoint(state, checkpoint_name): _get_alert_cursor_store(state.profile.name).delete(checkpoint_name) -@alerts.command("print") -@alert_options -@search_options -@sdk_options -def print_alerts(cli_state, format, begin, end, advanced_query, use_checkpoint, **kwargs): - """Print alerts to stdout.""" - output_logger = logger_factory.get_logger_for_stdout(format) - cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None - _extract( - sdk=cli_state.sdk, - cursor=cursor, - checkpoint_name=use_checkpoint, - filter_list=cli_state.search_filters, - begin=begin, - end=end, - advanced_query=advanced_query, - output_logger=output_logger, - ) - - @alerts.command() -@output_file_arg @alert_options @search_options @sdk_options -def write_to(cli_state, format, output_file, begin, end, advanced_query, use_checkpoint, **kwargs): - """Write alerts to the file with the given name.""" - output_logger = logger_factory.get_logger_for_file(output_file, format) - cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None - _extract( - sdk=cli_state.sdk, - cursor=cursor, - checkpoint_name=use_checkpoint, - filter_list=cli_state.search_filters, - begin=begin, - end=end, - advanced_query=advanced_query, - output_logger=output_logger, - ) - - -@alerts.command() -@server_options -@alert_options -@search_options -@sdk_options -def send_to( - cli_state, format, hostname, protocol, begin, end, advanced_query, use_checkpoint, **kwargs -): - """Send alerts to the given server address.""" - output_logger = logger_factory.get_logger_for_server(hostname, protocol, format) +def search(cli_state, format, begin, end, advanced_query, use_checkpoint, **kwargs): + """Search for alerts.""" + output_logger = logger_factory.get_logger_for_stdout(format) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None - _extract( - sdk=cli_state.sdk, - cursor=cursor, - checkpoint_name=use_checkpoint, - filter_list=cli_state.search_filters, - begin=begin, - end=end, - advanced_query=advanced_query, - output_logger=output_logger, - ) - - -def _extract(sdk, cursor, checkpoint_name, filter_list, begin, end, advanced_query, output_logger): - handlers = create_handlers(sdk, AlertExtractor, output_logger, cursor, checkpoint_name) - extractor = _get_alert_extractor(sdk, handlers) + handlers = create_handlers(cli_state.sdk, AlertExtractor, output_logger, cursor, use_checkpoint) + extractor = _get_alert_extractor(cli_state.sdk, handlers) if advanced_query: extractor.extract_advanced(advanced_query) else: if begin or end: - filter_list.append(create_time_range_filter(DateObserved, begin, end)) - extractor.extract(*filter_list) + cli_state.search_filters.append(create_time_range_filter(DateObserved, begin, end)) + extractor.extract(*cli_state.search_filters) if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: echo("No results found.") diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index ce60e51c6..f69c49b5c 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -1,5 +1,6 @@ from collections import OrderedDict from functools import lru_cache +import json from pprint import pformat import click @@ -181,7 +182,7 @@ def _remove_user_from_legal_hold(sdk, matter_id, username): def _get_and_print_preservation_policy(sdk, policy_uid): preservation_policy = sdk.legalhold.get_policy_by_uid(policy_uid) echo("\nPreservation Policy:\n") - echo(pformat(preservation_policy._data_root)) + echo(pformat(json.loads(preservation_policy.text))) def _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id): diff --git a/src/code42cli/cmds/search/logger_factory.py b/src/code42cli/cmds/search/logger_factory.py index afc8a2b9e..b5855eb1b 100644 --- a/src/code42cli/cmds/search/logger_factory.py +++ b/src/code42cli/cmds/search/logger_factory.py @@ -5,17 +5,12 @@ FileEventDictToJSONFormatter, FileEventDictToRawJSONFormatter, ) -from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper from code42cli.cmds.search.enums import OutputFormat -from code42cli.errors import Code42CLIError from code42cli.logger import ( - logger_has_handlers, - logger_deps_lock, add_handler_to_logger, get_logger_for_stdout as get_stdout_logger, ) -from code42cli.util import get_url_parts def get_logger_for_stdout(output_format): @@ -27,50 +22,6 @@ def get_logger_for_stdout(output_format): return get_stdout_logger(output_format.lower(), formatter) -def get_logger_for_file(filename, output_format): - """Gets the logger that logs to a file for the given format. - - Args: - filename: The name of the file to write logs to. - output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. - """ - logger = logging.getLogger("code42_file_{0}".format(output_format.lower())) - if logger_has_handlers(logger): - return logger - - with logger_deps_lock: - if not logger_has_handlers(logger): - handler = logging.FileHandler(filename, delay=True, encoding="utf-8") - return _init_logger(logger, handler, output_format) - return logger - - -def get_logger_for_server(hostname, protocol, output_format): - """Gets the logger that sends logs to a server for the given format. - - Args: - hostname: The hostname of the server. It may include the port. - protocol: The transfer protocol for sending logs. - output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. - """ - logger = logging.getLogger(u"code42_syslog_{0}".format(output_format.lower())) - if logger_has_handlers(logger): - return logger - - with logger_deps_lock: - if not logger_has_handlers(logger): - url_parts = get_url_parts(hostname) - port = url_parts[1] or 514 - try: - handler = NoPrioritySysLogHandlerWrapper( - url_parts[0], port=port, protocol=protocol - ).handler - except: - raise Code42CLIError("Unable to connect to {0}.".format(hostname)) - return _init_logger(logger, handler, output_format) - return logger - - def _init_logger(logger, handler, output_format): formatter = _get_formatter(output_format) logger.setLevel(logging.INFO) diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index d63257fe5..4f05c005b 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -152,22 +152,3 @@ def search_options(f): return f return search_options - - -output_file_arg = click.argument( - "output_file", type=click.Path(dir_okay=False, resolve_path=True, writable=True) -) - - -def server_options(f): - hostname_arg = click.argument("hostname") - protocol_option = click.option( - "-p", - "--protocol", - type=click.Choice(ServerProtocol()), - default=ServerProtocol.UDP, - help="Protocol used to send logs to server.", - ) - f = hostname_arg(f) - f = protocol_option(f) - return f diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index e678a2b4e..d922611e2 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -21,8 +21,6 @@ AdvancedQueryAndSavedSearchIncompatible, is_in_filter, exists_filter, - output_file_arg, - server_options, ) from code42cli.logger import get_main_cli_logger from code42cli.options import sdk_options, incompatible_with, OrderedGroup @@ -170,82 +168,26 @@ def clear_checkpoint(state, checkpoint_name): _get_file_event_cursor_store(state.profile.name).delete(checkpoint_name) -@security_data.command("print") -@file_event_options -@search_options -@sdk_options -def _print(state, format, begin, end, advanced_query, use_checkpoint, saved_search, **kwargs): - """Print file events to stdout.""" - output_logger = logger_factory.get_logger_for_stdout(format) - cursor = _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None - _extract( - sdk=state.sdk, - cursor=cursor, - checkpoint_name=use_checkpoint, - filter_list=state.search_filters, - begin=begin, - end=end, - advanced_query=advanced_query, - saved_search=saved_search, - output_logger=output_logger, - ) - - @security_data.command() -@output_file_arg @file_event_options @search_options @sdk_options -def write_to( - state, format, output_file, begin, end, advanced_query, use_checkpoint, saved_search, **kwargs -): - """Write file events to the file with the given name.""" - output_logger = logger_factory.get_logger_for_file(output_file, format) - cursor = _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None - _extract( - sdk=state.sdk, - cursor=cursor, - checkpoint_name=use_checkpoint, - filter_list=state.search_filters, - begin=begin, - end=end, - advanced_query=advanced_query, - saved_search=saved_search, - output_logger=output_logger, - ) - - -@security_data.command() -@server_options -@file_event_options -@search_options -@sdk_options -def send_to( - state, - format, - hostname, - protocol, - begin, - end, - advanced_query, - use_checkpoint, - saved_search, - **kwargs -): - """Send file events to the given server address.""" - output_logger = logger_factory.get_logger_for_server(hostname, protocol, format) +def search(state, format, begin, end, advanced_query, use_checkpoint, saved_search, **kwargs): + """Search for file events.""" + output_logger = logger_factory.get_logger_for_stdout(format) cursor = _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None - _extract( - sdk=state.sdk, - cursor=cursor, - checkpoint_name=use_checkpoint, - filter_list=state.search_filters, - begin=begin, - end=end, - advanced_query=advanced_query, - saved_search=saved_search, - output_logger=output_logger, - ) + handlers = create_handlers(state.sdk, FileEventExtractor, output_logger, cursor, use_checkpoint) + extractor = _get_file_event_extractor(state.sdk, handlers) + if advanced_query: + extractor.extract_advanced(advanced_query) + elif saved_search: + extractor.extract(*saved_search._filter_group_list) + else: + if begin or end: + state.search_filters.append(create_time_range_filter(EventTimestamp, begin, end)) + extractor.extract(*state.search_filters) + if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: + echo("No results found.") @security_data.group(cls=OrderedGroup) @@ -272,31 +214,6 @@ def show(state, search_id): echo(pformat(response["searches"])) -def _extract( - sdk, - cursor, - checkpoint_name, - filter_list, - begin, - end, - advanced_query, - saved_search, - output_logger, -): - handlers = create_handlers(sdk, FileEventExtractor, output_logger, cursor, checkpoint_name) - extractor = _get_file_event_extractor(sdk, handlers) - if advanced_query: - extractor.extract_advanced(advanced_query) - elif saved_search: - extractor.extract(*saved_search._filter_group_list) - else: - if begin or end: - filter_list.append(create_time_range_filter(EventTimestamp, begin, end)) - extractor.extract(*filter_list) - if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: - echo("No results found.") - - def _get_file_event_extractor(sdk, handlers): return FileEventExtractor(sdk, handlers) diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 2095a841d..bb0af997d 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -8,7 +8,7 @@ from code42cli.util import get_user_project_path # prevent loggers from printing stacks to stderr if a pipe is broken -logging.raiseExceptions = True +logging.raiseExceptions = False logger_deps_lock = Lock() ERROR_LOG_FILE_NAME = "code42_errors.log" diff --git a/src/code42cli/main.py b/src/code42cli/main.py index bead4bf08..ced637b88 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -31,7 +31,7 @@ # Handle KeyboardInterrupts by just exiting instead of printing out a stack def exit_on_interrupt(signal, frame): - click.echo() + click.echo(err=True) sys.exit(1) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index d34bfdcac..5561c098c 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,6 +1,5 @@ import os import shutil -import sys from collections import OrderedDict from functools import wraps from os import path @@ -30,18 +29,6 @@ def get_user_project_path(*subdirs): return result_path -def is_interactive(): - return sys.stdin.isatty() - - -def get_url_parts(url_str): - parts = url_str.split(":") - port = None - if len(parts) > 1 and parts[1] != "": - port = int(parts[1]) - return parts[0], port - - def find_format_width(record, header): """Fetches needed keys/items to be displayed based on header keys. diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index e22ddea44..c0a45f18a 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -40,20 +40,6 @@ def stdout_logger(mocker): return mock -@pytest.fixture -def server_logger(mocker): - mock = mocker.patch("{}.cmds.search.logger_factory.get_logger_for_server".format(PRODUCT_NAME)) - mock.return_value = mocker.MagicMock() - return mock - - -@pytest.fixture -def file_logger(mocker): - mock = mocker.patch("{}.cmds.search.logger_factory.get_logger_for_file".format(PRODUCT_NAME)) - mock.return_value = mocker.MagicMock() - return mock - - @pytest.fixture def cli_state_with_user(sdk_with_user, cli_state): cli_state.sdk = sdk_with_user diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 209ef09ff..912360b30 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -70,6 +70,7 @@ def alert_cursor_with_checkpoint(mocker): mock_cursor = mocker.MagicMock(spec=AlertCursorStore) mock_cursor.get.return_value = CURSOR_TIMESTAMP mock.return_value = mock_cursor + mock.expected_timestamp = "2020-01-20T06:00:00+00:00" return mock @@ -86,6 +87,7 @@ def alert_cursor_without_checkpoint(mocker): def begin_option(mocker): mock = mocker.patch("{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME)) mock.return_value = BEGIN_TIMESTAMP + mock.expected_timestamp = "2020-01-01T06:00:00.000Z" return mock @@ -97,53 +99,31 @@ def alert_extract_func(mocker): ADVANCED_QUERY_JSON = '{"some": "complex json"}' -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_is_advanced_query_uses_only_the_extract_advanced_method( - cmd, cli_state, alert_extractor +def test_search_with_advanced_query_uses_only_the_extract_advanced_method( + cli_state, alert_extractor, runner ): - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + cli, ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state ) alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') assert alert_extractor.extract.call_count == 0 -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_not_advanced_query_uses_only_the_extract_method(cmd, cli_state, alert_extractor): - runner = CliRunner() - result = runner.invoke(cli, ["alerts", *cmd, "--begin", "1d"], obj=cli_state) +def test_search_without_advanced_query_uses_only_the_extract_method( + cli_state, alert_extractor, runner +): + + result = runner.invoke(cli, ["alerts", "search", "--begin", "1d"], obj=cli_state) assert alert_extractor.extract.call_count == 1 assert alert_extractor.extract_advanced.call_count == 0 -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_is_advanced_query_and_has_begin_date_exits(cmd, cli_state): - runner = CliRunner() - result = runner.invoke( - cli, - ["alerts", *cmd, "--advanced-query", ADVANCED_QUERY_JSON, "--begin", "1d"], - obj=cli_state, - ) - assert result.exit_code == 2 - assert "--begin can't be used with: --advanced-query" in result.output - - -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_advanced_query_and_has_begin_date_exits(cmd, cli_state): - runner = CliRunner() - result = runner.invoke( - cli, - ["alerts", *cmd, "--advanced-query", ADVANCED_QUERY_JSON, "--end", "1d"], - obj=cli_state, - ) - assert result.exit_code == 2 - assert "--end can't be used with: --advanced-query" in result.output - - @pytest.mark.parametrize( "arg", [ + ("--begin", "1d"), + ("--end", "1d"), ("--severity", "HIGH"), ("--actor", "test"), ("--actor-contains", "test"), @@ -160,82 +140,23 @@ def test_when_advanced_query_and_has_begin_date_exits(cmd, cli_state): ("--use-checkpoint", "test"), ], ) -def test_print_when_advanced_query_and_other_incompatible_argument_passed(arg, cli_state): - runner = CliRunner() - result = runner.invoke( - cli, ["alerts", "print", "--advanced-query", ADVANCED_QUERY_JSON, *arg], obj=cli_state, - ) - assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output +def test_search_with_advanced_query_and_incompatible_argument_errors(arg, cli_state, runner): - -@pytest.mark.parametrize( - "arg", - [ - ("--severity", "HIGH"), - ("--actor", "test"), - ("--actor-contains", "test"), - ("--exclude-actor", "test"), - ("--exclude-actor-contains", "test"), - ("--rule-name", "test"), - ("--exclude-rule-name", "test"), - ("--rule-id", "test"), - ("--exclude-rule-id", "test"), - ("--rule-type", "FedEndpointExfiltration"), - ("--exclude-rule-type", "FedEndpointExfiltration"), - ("--description", "test"), - ("--state", "OPEN"), - ("--use-checkpoint", "test"), - ], -) -def test_write_to_when_advanced_query_and_other_incompatible_argument_passed(arg, cli_state): - runner = CliRunner() result = runner.invoke( - cli, - ["alerts", "write-to", "test_file", "--advanced-query", ADVANCED_QUERY_JSON, *arg], - obj=cli_state, + cli, ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON, *arg], obj=cli_state, ) assert result.exit_code == 2 assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output -@pytest.mark.parametrize( - "arg", - [ - ("--severity", "HIGH"), - ("--actor", "test"), - ("--actor-contains", "test"), - ("--exclude-actor", "test"), - ("--exclude-actor-contains", "test"), - ("--rule-name", "test"), - ("--exclude-rule-name", "test"), - ("--rule-id", "test"), - ("--exclude-rule-id", "test"), - ("--rule-type", "FedEndpointExfiltration"), - ("--exclude-rule-type", "FedEndpointExfiltration"), - ("--description", "test"), - ("--state", "OPEN"), - ("--use-checkpoint", "test"), - ], -) -def test_send_to_when_advanced_query_and_other_incompatible_argument_passed(arg, cli_state): - runner = CliRunner() - result = runner.invoke( - cli, - ["alerts", "send-to", "localhost", "--advanced-query", ADVANCED_QUERY_JSON, *arg], - obj=cli_state, - ) - assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output - - -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_begin_and_end_dates_uses_expected_query(cmd, cli_state, alert_extractor): +def test_search_when_given_begin_and_end_dates_uses_expected_query( + cli_state, alert_extractor, runner +): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", "print", "--begin", begin_date, "--end", end_date], obj=cli_state + cli, ["alerts", "search", "--begin", begin_date, "--end", end_date], obj=cli_state ) filters = alert_extractor.extract.call_args[0][0] actual_begin = get_filter_value_from_json(filters, filter_index=0) @@ -246,19 +167,17 @@ def test_when_given_begin_and_end_dates_uses_expected_query(cmd, cli_state, aler assert actual_end == expected_end -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_begin_and_end_date_and_time_uses_expected_query( - cmd, cli_state, alert_extractor +def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( + cli_state, alert_extractor, runner ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) time = "15:33:02" - runner = CliRunner() result = runner.invoke( cli, [ "alerts", - "print", + "search", "--begin", "{} {}".format(begin_date, time), "--end", @@ -275,30 +194,28 @@ def test_when_given_begin_and_end_date_and_time_uses_expected_query( assert actual_end == expected_end -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_begin_date_and_time_without_seconds_uses_expected_query( - cmd, cli_state, alert_extractor +def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_query( + cli_state, alert_extractor, runner ): date = get_test_date_str(days_ago=89) time = "15:33" - runner = CliRunner() result = runner.invoke( - cli, ["alerts", "print", "--begin", "{} {}".format(date, time)], obj=cli_state + cli, ["alerts", "search", "--begin", "{} {}".format(date, time)], obj=cli_state ) actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) expected = "{0}T{1}:00.000Z".format(date, time) assert actual == expected -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_end_date_and_time_uses_expected_query(cmd, cli_state, alert_extractor): +def test_search_when_given_end_date_and_time_uses_expected_query( + cli_state, alert_extractor, runner +): begin_date = get_test_date_str(days_ago=10) end_date = get_test_date_str(days_ago=1) time = "15:33" - runner = CliRunner() result = runner.invoke( cli, - ["alerts", "print", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], + ["alerts", "search", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], obj=cli_state, ) actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) @@ -306,50 +223,39 @@ def test_when_given_end_date_and_time_uses_expected_query(cmd, cli_state, alert_ assert actual == expected -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_begin_date_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( - cmd, cli_state, -): +def test_search_when_given_begin_date_more_than_ninety_days_back_errors(cli_state, runner): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - runner = CliRunner() - result = runner.invoke(cli, ["alerts", *cmd, "--begin", begin_date], obj=cli_state) + result = runner.invoke(cli, ["alerts", "search", "--begin", begin_date], obj=cli_state) assert result.exit_code == 2 assert "must be within 90 days" in result.output -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - cmd, cli_state, alert_cursor_with_checkpoint, mocker, alert_extractor +def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + cli_state, alert_cursor_with_checkpoint, mocker, alert_extractor, runner ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - runner = CliRunner() result = runner.invoke( - cli, ["alerts", *cmd, "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state + cli, ["alerts", "search", "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state ) assert not filter_term_is_in_call_args(alert_extractor, DateObserved._term) -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( - cmd, cli_state, alert_extractor +def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( + cli_state, alert_extractor, runner ): begin_date = get_test_date_str(days_ago=1) - runner = CliRunner() - result = runner.invoke(cli, ["alerts", *cmd, "--begin", begin_date], obj=cli_state) - + result = runner.invoke(cli, ["alerts", "search", "--begin", begin_date], obj=cli_state) actual_ts = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) expected_ts = "{0}T00:00:00.000Z".format(begin_date) assert actual_ts == expected_ts assert filter_term_is_in_call_args(alert_extractor, DateObserved._term) -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_end_date_is_before_begin_date_causes_exit(cmd, cli_state): +def test_search_when_end_date_is_before_begin_date_causes_exit(cli_state, runner): begin_date = get_test_date_str(days_ago=1) end_date = get_test_date_str(days_ago=3) - runner = CliRunner() result = runner.invoke( - cli, ["alerts", *cmd, "--begin", begin_date, "--end", end_date], obj=cli_state + cli, ["alerts", "search", "--begin", begin_date, "--end", end_date], obj=cli_state ) assert result.exit_code == 2 assert "'--begin': cannot be after --end date" in result.output @@ -369,66 +275,26 @@ def test_get_alert_details_sorts_results_by_date(sdk): assert results == SORTED_ALERT_DETAILS -def test_print_with_only_begin_calls_extract_with_expected_args( - mocker, cli_state, alert_extract_func, stdout_logger, begin_option +def test_search_with_only_begin_calls_extract_with_expected_filters( + mocker, cli_state, alert_extractor, stdout_logger, begin_option, runner ): - runner = CliRunner() - result = runner.invoke(cli, ["alerts", "print", "--begin", "1h"], obj=cli_state) - alert_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=None, - checkpoint_name=None, - filter_list=cli_state.search_filters, - begin=BEGIN_TIMESTAMP, - end=None, - advanced_query=None, - output_logger=stdout_logger.return_value, - ) - assert result.exit_code == 0 - -def test_send_to_with_only_begin_calls_extract_with_expected_args( - mocker, cli_state, alert_extract_func, server_logger, begin_option -): - runner = CliRunner() - result = runner.invoke(cli, ["alerts", "send-to", "localhost", "--begin", "1h"], obj=cli_state) - alert_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=None, - checkpoint_name=None, - filter_list=cli_state.search_filters, - begin=BEGIN_TIMESTAMP, - end=None, - advanced_query=None, - output_logger=server_logger.return_value, + result = runner.invoke( + cli, ["alerts", "search", "--begin", ""], obj=cli_state ) assert result.exit_code == 0 - - -def test_write_to_with_only_begin_calls_extract_with_expected_args( - mocker, cli_state, alert_extract_func, file_logger, begin_option -): - runner = CliRunner() - result = runner.invoke(cli, ["alerts", "write-to", "test_file", "--begin", "1h"], obj=cli_state) - alert_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=None, - checkpoint_name=None, - filter_list=cli_state.search_filters, - begin=BEGIN_TIMESTAMP, - end=None, - advanced_query=None, - output_logger=file_logger.return_value, + assert str( + alert_extractor.extract.call_args[0][0] + ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"createdAt", ' '"value":"{}"}}]}}'.format( + begin_option.expected_timestamp ) - assert result.exit_code == 0 -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_expected_error( - cmd, cli_state, alert_cursor_without_checkpoint +def test_search_with_use_checkpoint_and_without_begin_and_without_stored_checkpoint_causes_expected_error( + cli_state, alert_cursor_without_checkpoint, runner ): - runner = CliRunner() - result = runner.invoke(cli, ["alerts", *cmd, "--use-checkpoint", "test"], obj=cli_state) + + result = runner.invoke(cli, ["alerts", "search", "--use-checkpoint", "test"], obj=cli_state) assert result.exit_code == 2 assert ( "--begin date is required for --use-checkpoint when no checkpoint exists yet." @@ -436,174 +302,149 @@ def test_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_exp ) -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( - cmd, cli_state, - alert_extract_func, + alert_extractor, begin_option, alert_cursor_without_checkpoint, stdout_logger, - server_logger, - file_logger, mocker, + runner, ): - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + cli, + ["alerts", "search", "--use-checkpoint", "test", "--begin", ""], + obj=cli_state, ) assert result.exit_code == 0 - alert_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=alert_cursor_without_checkpoint.return_value, - checkpoint_name="test", - filter_list=cli_state.search_filters, - begin=BEGIN_TIMESTAMP, - end=None, - advanced_query=None, - output_logger=mocker.ANY, - ) + assert len(alert_extractor.extract.call_args[0]) == 1 + assert begin_option.expected_timestamp in str(alert_extractor.extract.call_args[0][0]) -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_with_use_checkpoint_and_with_begin_and_with_checkpoint_calls_extract_with_begin_date_none( - cmd, - cli_state, - alert_extract_func, - alert_cursor_with_checkpoint, - stdout_logger, - server_logger, - file_logger, - mocker, +def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( + cli_state, alert_extractor, alert_cursor_with_checkpoint, runner ): - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + cli, ["alerts", "search", "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state ) assert result.exit_code == 0 - alert_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=alert_cursor_with_checkpoint.return_value, - checkpoint_name="test", - filter_list=cli_state.search_filters, - begin=None, - end=None, - advanced_query=None, - output_logger=mocker.ANY, + alert_extractor.extract.assert_called_with() + assert ( + "checkpoint of {} exists".format(alert_cursor_with_checkpoint.expected_timestamp) + in result.output ) - assert "checkpoint of 2020-01-20T06:00:00+00:00 exists" in result.output -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_actor_is_uses_username_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_actor_is_uses_username_filter(cli_state, alert_extractor, runner): actor_name = "test.testerson" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--actor", actor_name], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--actor", actor_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(Actor.is_in([actor_name])) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_exclude_actor_uses_actor_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_exclude_actor_uses_actor_filter(cli_state, alert_extractor, runner): actor_name = "test.testerson" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--exclude-actor", actor_name], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--exclude-actor", actor_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(Actor.not_in([actor_name])) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_rule_name_uses_rule_name_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_rule_name_uses_rule_name_filter(cli_state, alert_extractor, runner): rule_name = "departing employee" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--rule-name", rule_name], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--rule-name", rule_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(RuleName.is_in([rule_name])) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_exclude_rule_name_uses_rule_name_not_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_exclude_rule_name_uses_rule_name_not_filter( + cli_state, alert_extractor, runner +): rule_name = "departing employee" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--exclude-rule-name", rule_name], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-name", rule_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(RuleName.not_in([rule_name])) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_rule_type_uses_rule_name_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_rule_type_uses_rule_name_filter(cli_state, alert_extractor, runner): rule_type = "FedEndpointExfiltration" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--rule-type", rule_type], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--rule-type", rule_type], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(RuleType.is_in([rule_type])) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_exclude_rule_type_uses_rule_name_not_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_exclude_rule_type_uses_rule_name_not_filter( + cli_state, alert_extractor, runner +): rule_type = "FedEndpointExfiltration" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--exclude-rule-type", rule_type], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-type", rule_type], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(RuleType.not_in([rule_type])) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_rule_id_uses_rule_name_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_rule_id_uses_rule_name_filter(cli_state, alert_extractor, runner): rule_id = "departing employee" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--rule-id", rule_id], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--rule-id", rule_id], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(RuleId.is_in([rule_id])) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_exclude_rule_id_uses_rule_name_not_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_exclude_rule_id_uses_rule_name_not_filter( + cli_state, alert_extractor, runner +): rule_id = "departing employee" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--exclude-rule-id", rule_id], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-id", rule_id], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(RuleId.not_in([rule_id])) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_description_uses_description_filter(cmd, cli_state, alert_extractor): +def test_search_when_given_description_uses_description_filter(cli_state, alert_extractor, runner): description = "test description" - runner = CliRunner() + result = runner.invoke( - cli, ["alerts", *cmd, "--begin", "1h", "--description", description], obj=cli_state + cli, ["alerts", "search", "--begin", "1h", "--description", description], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(Description.contains(description)) in filter_strings -@pytest.mark.parametrize("cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]]) -def test_when_given_multiple_search_args_uses_expected_filters(cmd, cli_state, alert_extractor): +def test_search_when_given_multiple_search_args_uses_expected_filters( + cli_state, alert_extractor, runner +): actor = "test.testerson@example.com" exclude_actor = "flag.flagerson@code42.com" rule_name = "departing employee" - runner = CliRunner() + result = runner.invoke( cli, [ "alerts", - *cmd, + "search", "--begin", "1h", "--actor", diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index f1621a37d..899e35d98 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -29,6 +29,7 @@ def file_event_cursor_with_checkpoint(mocker): mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) mock_cursor.get.return_value = CURSOR_TIMESTAMP mock.return_value = mock_cursor + mock.expected_timestamp = "2020-01-20T06:00:00+00:00" return mock @@ -41,26 +42,37 @@ def file_event_cursor_without_checkpoint(mocker): return mock -@pytest.fixture -def file_event_extract_func(mocker): - return mocker.patch("{}.cmds.securitydata._extract".format(PRODUCT_NAME)) - - @pytest.fixture def begin_option(mocker): mock = mocker.patch("{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME)) mock.return_value = BEGIN_TIMESTAMP + mock.expected_timestamp = "2020-01-01T06:00:00.000Z" return mock ADVANCED_QUERY_JSON = '{"some": "complex json"}' -parametrize_search_output_cmds = pytest.mark.parametrize( - "cmd", [["print"], ["send-to", "localhost"], ["write-to", "test_file"]] -) +def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( + runner, cli_state, file_event_extractor +): + result = runner.invoke( + cli, ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + ) + file_event_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') + assert file_event_extractor.extract.call_count == 0 + assert file_event_extractor.extract_advanced.call_count == 1 + + +def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( + runner, cli_state, file_event_extractor +): + result = runner.invoke(cli, ["security-data", "search", "--begin", "1d"], obj=cli_state) + assert file_event_extractor.extract_advanced.call_count == 0 + assert file_event_extractor.extract.call_count == 1 -parametrize_incompatible_args = pytest.mark.parametrize( + +@pytest.mark.parametrize( "arg", [ ("--begin", "1d"), @@ -79,103 +91,50 @@ def begin_option(mocker): ("--use-checkpoint", "test"), ], ) - - -@parametrize_search_output_cmds -def test_when_is_advanced_query_uses_only_the_extract_advanced_method( - runner, cmd, cli_state, file_event_extractor -): - result = runner.invoke( - cli, ["security-data", *cmd, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state - ) - file_event_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') - assert file_event_extractor.extract.call_count == 0 - assert file_event_extractor.extract_advanced.call_count == 1 - - -@parametrize_search_output_cmds -def test_when_is_advanced_query_uses_only_the_extract_advanced_method( - runner, cmd, cli_state, file_event_extractor -): - result = runner.invoke(cli, ["security-data", *cmd, "--begin", "1d"], obj=cli_state) - assert file_event_extractor.extract_advanced.call_count == 0 - assert file_event_extractor.extract.call_count == 1 - - -@parametrize_incompatible_args -def test_print_when_advanced_query_and_other_incompatible_argument_passed(runner, arg, cli_state): - result = runner.invoke( - cli, - ["security-data", "print", "--advanced-query", ADVANCED_QUERY_JSON, *arg], - obj=cli_state, - ) - assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output - - -@parametrize_incompatible_args -def test_print_when_saved_search_and_other_incompatible_argument_passed(runner, arg, cli_state): - result = runner.invoke( - cli, ["security-data", "print", "--saved-search", "test_id", *arg], obj=cli_state, - ) - assert result.exit_code == 2 - assert "{} can't be used with: --saved-search".format(arg[0]) in result.output - - -@parametrize_incompatible_args -def test_write_to_when_advanced_query_and_other_incompatible_argument_passed( - runner, arg, cli_state -): - result = runner.invoke( - cli, - ["security-data", "write-to", "test_file", "--advanced-query", ADVANCED_QUERY_JSON, *arg], - obj=cli_state, - ) - assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output - - -@parametrize_incompatible_args -def test_write_to_when_saved_search_and_other_incompatible_argument_passed(runner, arg, cli_state): +def test_search_with_advanced_query_and_incompatible_argument_errors(runner, arg, cli_state): result = runner.invoke( cli, - ["security-data", "write-to", "test_file", "--saved-search", "test_id", *arg], - obj=cli_state, - ) - assert result.exit_code == 2 - assert "{} can't be used with: --saved-search".format(arg[0]) in result.output - - -@parametrize_incompatible_args -def test_send_to_when_advanced_query_and_other_incompatible_argument_passed(runner, arg, cli_state): - result = runner.invoke( - cli, - ["security-data", "send-to", "localhost", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON, *arg], obj=cli_state, ) assert result.exit_code == 2 assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output -@parametrize_incompatible_args -def test_send_to_when_saved_search_and_other_incompatible_argument_passed(runner, arg, cli_state): +@pytest.mark.parametrize( + "arg", + [ + ("--begin", "1d"), + ("--end", "1d"), + ("--c42-username", "test@code42.com"), + ("--actor", "test.testerson"), + ("--md5", "abcd1234"), + ("--sha256", "abcdefg12345678"), + ("--source", "Gmail"), + ("--file-name", "test.txt"), + ("--file-path", "C:\\Program Files"), + ("--process-owner", "root"), + ("--tab-url", "https://example.com"), + ("--type", "SharedViaLink"), + ("--include-non-exposure",), + ("--use-checkpoint", "test"), + ], +) +def test_serach_with_saved_search_and_incompatible_argument_errors(runner, arg, cli_state): result = runner.invoke( - cli, - ["security-data", "send-to", "localhost", "--saved-search", "test_id", *arg], - obj=cli_state, + cli, ["security-data", "search", "--saved-search", "test_id", *arg], obj=cli_state, ) assert result.exit_code == 2 assert "{} can't be used with: --saved-search".format(arg[0]) in result.output -@parametrize_search_output_cmds -def test_when_given_begin_and_end_dates_uses_expected_query( - runner, cmd, cli_state, file_event_extractor +def test_search_when_given_begin_and_end_dates_uses_expected_query( + runner, cli_state, file_event_extractor ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) result = runner.invoke( - cli, ["security-data", "print", "--begin", begin_date, "--end", end_date], obj=cli_state + cli, ["security-data", "search", "--begin", begin_date, "--end", end_date], obj=cli_state ) filters = file_event_extractor.extract.call_args[0][1] actual_begin = get_filter_value_from_json(filters, filter_index=0) @@ -186,9 +145,8 @@ def test_when_given_begin_and_end_dates_uses_expected_query( assert actual_end == expected_end -@parametrize_search_output_cmds -def test_when_given_begin_and_end_date_and_time_uses_expected_query( - runner, cmd, cli_state, file_event_extractor +def test_search_when_given_begin_and_end_date_and_time_uses_expected_query( + runner, cli_state, file_event_extractor ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) @@ -197,7 +155,7 @@ def test_when_given_begin_and_end_date_and_time_uses_expected_query( cli, [ "security-data", - "print", + "search", "--begin", "{} {}".format(begin_date, time), "--end", @@ -214,14 +172,13 @@ def test_when_given_begin_and_end_date_and_time_uses_expected_query( assert actual_end == expected_end -@parametrize_search_output_cmds -def test_when_given_begin_date_and_time_without_seconds_uses_expected_query( - runner, cmd, cli_state, file_event_extractor +def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_query( + runner, cli_state, file_event_extractor ): date = get_test_date_str(days_ago=89) time = "15:33" result = runner.invoke( - cli, ["security-data", "print", "--begin", "{} {}".format(date, time)], obj=cli_state + cli, ["security-data", "search", "--begin", "{} {}".format(date, time)], obj=cli_state ) actual = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 @@ -230,16 +187,15 @@ def test_when_given_begin_date_and_time_without_seconds_uses_expected_query( assert actual == expected -@parametrize_search_output_cmds -def test_when_given_end_date_and_time_uses_expected_query( - runner, cmd, cli_state, file_event_extractor +def test_search_when_given_end_date_and_time_uses_expected_query( + runner, cli_state, file_event_extractor ): begin_date = get_test_date_str(days_ago=10) end_date = get_test_date_str(days_ago=1) time = "15:33" result = runner.invoke( cli, - ["security-data", "print", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], + ["security-data", "search", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], obj=cli_state, ) actual = get_filter_value_from_json( @@ -249,36 +205,32 @@ def test_when_given_end_date_and_time_uses_expected_query( assert actual == expected -@parametrize_search_output_cmds -def test_when_given_begin_date_more_than_ninety_days_back_in_ad_hoc_mode_causes_exit( - runner, cmd, cli_state, +def test_search_when_given_begin_date_more_than_ninety_days_back_errors( + runner, cli_state, ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - result = runner.invoke(cli, ["security-data", *cmd, "--begin", begin_date], obj=cli_state) + result = runner.invoke(cli, ["security-data", "search", "--begin", begin_date], obj=cli_state) assert result.exit_code == 2 assert "must be within 90 days" in result.output -@parametrize_search_output_cmds -def test_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - runner, cmd, cli_state, file_event_cursor_with_checkpoint, mocker, file_event_extractor +def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + runner, cli_state, file_event_cursor_with_checkpoint, mocker, file_event_extractor ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" result = runner.invoke( cli, - ["security-data", *cmd, "--begin", begin_date, "--use-checkpoint", "test"], + ["security-data", "search", "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state, ) assert not filter_term_is_in_call_args(file_event_extractor, InsertionTimestamp._term) -@parametrize_search_output_cmds -def test_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( - runner, cmd, cli_state, file_event_extractor +def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( + runner, cli_state, file_event_extractor ): begin_date = get_test_date_str(days_ago=1) - result = runner.invoke(cli, ["security-data", *cmd, "--begin", begin_date], obj=cli_state) - + result = runner.invoke(cli, ["security-data", "search", "--begin", begin_date], obj=cli_state) actual_ts = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 ) @@ -287,80 +239,34 @@ def test_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_beg assert filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) -@parametrize_search_output_cmds -def test_when_end_date_is_before_begin_date_causes_exit(runner, cmd, cli_state): +def test_search_when_end_date_is_before_begin_date_causes_exit(runner, cli_state): begin_date = get_test_date_str(days_ago=1) end_date = get_test_date_str(days_ago=3) result = runner.invoke( - cli, ["security-data", *cmd, "--begin", begin_date, "--end", end_date], obj=cli_state + cli, ["security-data", "search", "--begin", begin_date, "--end", end_date], obj=cli_state ) assert result.exit_code == 2 assert "'--begin': cannot be after --end date" in result.output -def test_print_with_only_begin_calls_extract_with_expected_args( - runner, cli_state, file_event_extract_func, stdout_logger, begin_option +def test_search_with_only_begin_calls_extract_with_expected_args( + runner, cli_state, file_event_extractor, stdout_logger, begin_option ): - result = runner.invoke(cli, ["security-data", "print", "--begin", "1h"], obj=cli_state) - file_event_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=None, - checkpoint_name=None, - filter_list=cli_state.search_filters, - begin=BEGIN_TIMESTAMP, - end=None, - advanced_query=None, - saved_search=None, - output_logger=stdout_logger.return_value, - ) + result = runner.invoke(cli, ["security-data", "search", "--begin", "1h"], obj=cli_state) assert result.exit_code == 0 - - -def test_send_to_with_only_begin_calls_extract_with_expected_args( - runner, cli_state, file_event_extract_func, server_logger, begin_option -): - result = runner.invoke( - cli, ["security-data", "send-to", "localhost", "--begin", "1h"], obj=cli_state - ) - file_event_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=None, - checkpoint_name=None, - filter_list=cli_state.search_filters, - begin=BEGIN_TIMESTAMP, - end=None, - advanced_query=None, - saved_search=None, - output_logger=server_logger.return_value, + assert str( + file_event_extractor.extract.call_args[0][1] + ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"eventTimestamp", ' '"value":"{}"}}]}}'.format( + begin_option.expected_timestamp ) - assert result.exit_code == 0 -def test_write_to_with_only_begin_calls_extract_with_expected_args( - runner, cli_state, file_event_extract_func, file_logger, begin_option +def test_search_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_expected_error( + runner, cli_state, file_event_cursor_without_checkpoint ): result = runner.invoke( - cli, ["security-data", "write-to", "test_file", "--begin", "1h"], obj=cli_state - ) - file_event_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=None, - checkpoint_name=None, - filter_list=cli_state.search_filters, - begin=BEGIN_TIMESTAMP, - end=None, - advanced_query=None, - saved_search=None, - output_logger=file_logger.return_value, + cli, ["security-data", "search", "--use-checkpoint", "test"], obj=cli_state ) - assert result.exit_code == 0 - - -@parametrize_search_output_cmds -def test_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_expected_error( - runner, cmd, cli_state, file_event_cursor_without_checkpoint -): - result = runner.invoke(cli, ["security-data", *cmd, "--use-checkpoint", "test"], obj=cli_state) assert result.exit_code == 2 assert ( "--begin date is required for --use-checkpoint when no checkpoint exists yet." @@ -368,206 +274,168 @@ def test_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_exp ) -@parametrize_search_output_cmds -def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( +def test_search_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( runner, - cmd, cli_state, - file_event_extract_func, + file_event_extractor, begin_option, file_event_cursor_without_checkpoint, stdout_logger, - server_logger, - file_logger, mocker, ): result = runner.invoke( - cli, ["security-data", *cmd, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + cli, ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state ) assert result.exit_code == 0 - file_event_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=file_event_cursor_without_checkpoint.return_value, - checkpoint_name="test", - filter_list=cli_state.search_filters, - begin=BEGIN_TIMESTAMP, - end=None, - advanced_query=None, - saved_search=None, - output_logger=mocker.ANY, - ) + assert len(file_event_extractor.extract.call_args[0]) == 2 + assert begin_option.expected_timestamp in str(file_event_extractor.extract.call_args[0][1]) -@parametrize_search_output_cmds -def test_with_use_checkpoint_and_with_begin_and_with_checkpoint_calls_extract_with_begin_date_none( +def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( runner, - cmd, cli_state, - file_event_extract_func, + file_event_extractor, file_event_cursor_with_checkpoint, stdout_logger, - server_logger, - file_logger, mocker, ): result = runner.invoke( - cli, ["security-data", *cmd, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + cli, ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state ) assert result.exit_code == 0 - file_event_extract_func.assert_called_with( - sdk=cli_state.sdk, - cursor=file_event_cursor_with_checkpoint.return_value, - checkpoint_name="test", - filter_list=cli_state.search_filters, - begin=None, - end=None, - advanced_query=None, - saved_search=None, - output_logger=mocker.ANY, - ) - assert "checkpoint of 2020-01-20T06:00:00+00:00 exists" in result.output - - -@parametrize_search_output_cmds -def test_extract_when_given_invalid_exposure_type_causes_exit(runner, cmd, cli_state): + assert len(file_event_extractor.extract.call_args[0]) == 1 + assert ( + "checkpoint of {} exists".format(file_event_cursor_with_checkpoint.expected_timestamp) + in result.output + ) + + +def test_search_when_given_invalid_exposure_type_causes_exit(runner, cli_state): result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1d", "-t", "NotValid"], obj=cli_state + cli, ["security-data", "search", "--begin", "1d", "-t", "NotValid"], obj=cli_state ) assert result.exit_code == 2 assert "invalid choice: NotValid" in result.output -@parametrize_search_output_cmds -def test_when_given_username_uses_username_filter(runner, cmd, cli_state, file_event_extractor): +def test_search_when_given_username_uses_username_filter(runner, cli_state, file_event_extractor): c42_username = "test@code42.com" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--c42-username", c42_username], obj=cli_state + cli, + ["security-data", "search", "--begin", "1h", "--c42-username", c42_username], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(DeviceUsername.is_in([c42_username])) in filter_strings -@parametrize_search_output_cmds -def test_when_given_actor_is_uses_username_filter(runner, cmd, cli_state, file_event_extractor): +def test_search_when_given_actor_is_uses_username_filter(runner, cli_state, file_event_extractor): actor_name = "test.testerson" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--actor", actor_name], obj=cli_state + cli, ["security-data", "search", "--begin", "1h", "--actor", actor_name], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(Actor.is_in([actor_name])) in filter_strings -@parametrize_search_output_cmds -def test_when_given_md5_uses_md5_filter(runner, cmd, cli_state, file_event_extractor): +def test_search_when_given_md5_uses_md5_filter(runner, cli_state, file_event_extractor): md5 = "abcd12345" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--md5", md5], obj=cli_state + cli, ["security-data", "search", "--begin", "1h", "--md5", md5], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(MD5.is_in([md5])) in filter_strings -@parametrize_search_output_cmds -def test_when_given_sha256_uses_sha256_filter(runner, cmd, cli_state, file_event_extractor): +def test_search_when_given_sha256_uses_sha256_filter(runner, cli_state, file_event_extractor): sha_256 = "abcd12345" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--sha256", sha_256], obj=cli_state + cli, ["security-data", "search", "--begin", "1h", "--sha256", sha_256], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(SHA256.is_in([sha_256])) in filter_strings -@parametrize_search_output_cmds -def test_when_given_source_uses_source_filter(runner, cmd, cli_state, file_event_extractor): +def test_search_when_given_source_uses_source_filter(runner, cli_state, file_event_extractor): source = "Gmail" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--source", source], obj=cli_state + cli, ["security-data", "search", "--begin", "1h", "--source", source], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(Source.is_in([source])) in filter_strings -@parametrize_search_output_cmds -def test_when_given_file_name_uses_file_name_filter(runner, cmd, cli_state, file_event_extractor): +def test_search_when_given_file_name_uses_file_name_filter(runner, cli_state, file_event_extractor): filename = "test.txt" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--file-name", filename], obj=cli_state + cli, ["security-data", "search", "--begin", "1h", "--file-name", filename], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(FileName.is_in([filename])) in filter_strings -@parametrize_search_output_cmds -def test_when_given_file_path_uses_file_path_filter(runner, cmd, cli_state, file_event_extractor): +def test_searcg_when_given_file_path_uses_file_path_filter(runner, cli_state, file_event_extractor): filepath = "C:\\Program Files" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--file-path", filepath], obj=cli_state + cli, ["security-data", "search", "--begin", "1h", "--file-path", filepath], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(FilePath.is_in([filepath])) in filter_strings -@parametrize_search_output_cmds def test_when_given_process_owner_uses_process_owner_filter( - runner, cmd, cli_state, file_event_extractor + runner, cli_state, file_event_extractor ): process_owner = "root" result = runner.invoke( cli, - ["security-data", *cmd, "--begin", "1h", "--process-owner", process_owner], + ["security-data", "search", "--begin", "1h", "--process-owner", process_owner], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(ProcessOwner.is_in([process_owner])) in filter_strings -@parametrize_search_output_cmds -def test_when_given_tab_url_uses_process_tab_url_filter( - runner, cmd, cli_state, file_event_extractor -): +def test_when_given_tab_url_uses_process_tab_url_filter(runner, cli_state, file_event_extractor): tab_url = "https://example.com" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--tab-url", tab_url], obj=cli_state, + cli, ["security-data", "search", "--begin", "1h", "--tab-url", tab_url], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(TabURL.is_in([tab_url])) in filter_strings -@parametrize_search_output_cmds def test_when_given_exposure_types_uses_exposure_type_is_in_filter( - runner, cmd, cli_state, file_event_extractor + runner, cli_state, file_event_extractor ): exposure_type = "SharedViaLink" result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--type", exposure_type], obj=cli_state, + cli, ["security-data", "search", "--begin", "1h", "--type", exposure_type], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(ExposureType.is_in([exposure_type])) in filter_strings -@parametrize_search_output_cmds def test_when_given_include_non_exposure_does_not_include_exposure_type_exists( - runner, cmd, cli_state, file_event_extractor + runner, cli_state, file_event_extractor ): result = runner.invoke( - cli, ["security-data", *cmd, "--begin", "1h", "--include-non-exposure"], obj=cli_state, + cli, ["security-data", "search", "--begin", "1h", "--include-non-exposure"], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(ExposureType.exists()) not in filter_strings -@parametrize_search_output_cmds def test_when_not_given_include_non_exposure_includes_exposure_type_exists( - runner, cmd, cli_state, file_event_extractor + runner, cli_state, file_event_extractor ): - result = runner.invoke(cli, ["security-data", *cmd, "--begin", "1h"], obj=cli_state,) + result = runner.invoke(cli, ["security-data", "search", "--begin", "1h"], obj=cli_state,) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(ExposureType.exists()) in filter_strings -@parametrize_search_output_cmds def test_when_given_multiple_search_args_uses_expected_filters( - runner, cmd, cli_state, file_event_extractor + runner, cli_state, file_event_extractor ): process_owner = "root" c42_username = "test@code42.com" @@ -576,7 +444,7 @@ def test_when_given_multiple_search_args_uses_expected_filters( cli, [ "security-data", - *cmd, + "search", "--begin", "1h", "--process-owner", @@ -594,15 +462,14 @@ def test_when_given_multiple_search_args_uses_expected_filters( assert str(DeviceUsername.is_in([c42_username])) in filter_strings -@parametrize_search_output_cmds def test_when_given_include_non_exposure_and_exposure_types_causes_exit( - runner, cmd, cli_state, file_event_extractor + runner, cli_state, file_event_extractor ): result = runner.invoke( cli, [ "security-data", - *cmd, + "search", "--begin", "1h", "--include-non-exposure", @@ -614,9 +481,8 @@ def test_when_given_include_non_exposure_and_exposure_types_causes_exit( assert result.exit_code == 2 -@parametrize_search_output_cmds def test_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( - runner, cmd, cli_state, mocker, caplog + runner, cli_state, mocker, caplog ): errors.ERRORED = False exception_msg = "Test Exception" @@ -626,15 +492,14 @@ def file_search_error(x): cli_state.sdk.securitydata.search_file_events.side_effect = file_search_error with caplog.at_level(logging.ERROR): - result = runner.invoke(cli, ["security-data", *cmd, "--begin", "1d"], obj=cli_state) + result = runner.invoke(cli, ["security-data", "search", "--begin", "1d"], obj=cli_state) assert exception_msg in result.output assert exception_msg in caplog.text assert errors.ERRORED -@parametrize_search_output_cmds def test_saved_search_calls_extractor_extract_and_saved_search_execute( - runner, cmd, cli_state, file_event_extractor + runner, cli_state, file_event_extractor ): search_query = { "groupClause": "AND", @@ -667,7 +532,9 @@ def test_saved_search_calls_extractor_extract_and_saved_search_execute( } query = FileEventQuery.from_dict(search_query) cli_state.sdk.securitydata.savedsearches.get_query.return_value = query - result = runner.invoke(cli, ["security-data", *cmd, "--saved-search", "test_id"], obj=cli_state) + result = runner.invoke( + cli, ["security-data", "search", "--saved-search", "test_id"], obj=cli_state + ) assert file_event_extractor.extract.call_count == 1 assert str(file_event_extractor.extract.call_args[0][0]) in str(query) assert str(file_event_extractor.extract.call_args[0][1]) in str(query) diff --git a/tests/test_logger_factory.py b/tests/test_logger_factory.py index 334b17f13..62c467dfb 100644 --- a/tests/test_logger_factory.py +++ b/tests/test_logger_factory.py @@ -10,15 +10,6 @@ import code42cli.cmds.search.logger_factory as factory -@pytest.fixture -def no_priority_syslog_handler(mocker): - mock = mocker.patch("c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.handler") - - # Set handlers to empty list so it gets initialized each test - factory.get_logger_for_server("example.com", "TCP", "CEF").handlers = [] - return mock - - def test_get_logger_for_stdout_has_info_level(): logger = factory.get_logger_for_stdout("CEF") assert logger.level == logging.INFO @@ -48,106 +39,3 @@ def test_get_logger_for_stdout_when_called_twice_has_only_one_handler(): def test_get_logger_for_stdout_uses_stream_handler(): logger = factory.get_logger_for_stdout("CEF") assert type(logger.handlers[0]) == logging.StreamHandler - - -def test_get_logger_for_file_has_info_level(): - logger = factory.get_logger_for_file("Test.out", "CEF") - assert logger.level == logging.INFO - - -def test_get_logger_for_file_when_given_cef_format_uses_cef_formatter(): - logger = factory.get_logger_for_file("Test.out", "CEF") - assert type(logger.handlers[0].formatter) == FileEventDictToCEFFormatter - - -def test_get_logger_for_file_when_given_json_format_uses_json_formatter(): - logger = factory.get_logger_for_file("Test.out", "JSON") - assert type(logger.handlers[0].formatter) == FileEventDictToJSONFormatter - - -def test_get_logger_for_file_when_given_raw_json_format_uses_raw_json_formatter(): - logger = factory.get_logger_for_file("Test.out", "RAW-JSON") - assert type(logger.handlers[0].formatter) == FileEventDictToRawJSONFormatter - - -def test_get_logger_for_file_when_called_twice_has_only_one_handler(): - factory.get_logger_for_file("Test.out", "JSON") - logger = factory.get_logger_for_file("Test.out", "JSON") - assert type(logger.handlers[0].formatter) == FileEventDictToJSONFormatter - - -def test_get_logger_for_file_uses_file_handler(): - logger = factory.get_logger_for_file("Test.out", "JSON") - assert type(logger.handlers[0]) == logging.FileHandler - - -def test_get_logger_for_file_uses_given_file_name(): - logger = factory.get_logger_for_file("Test.out", "JSON") - assert logger.handlers[0].baseFilename[-8:] == "Test.out" - - -def test_get_logger_for_server_has_info_level(no_priority_syslog_handler): - logger = factory.get_logger_for_server("example.com", "TCP", "CEF") - assert logger.level == logging.INFO - - -def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(no_priority_syslog_handler): - factory.get_logger_for_server("example.com", "TCP", "CEF") - assert ( - type(no_priority_syslog_handler.setFormatter.call_args[0][0]) == FileEventDictToCEFFormatter - ) - - -def test_get_logger_for_server_when_given_json_format_uses_json_formatter( - no_priority_syslog_handler, -): - factory.get_logger_for_server("example.com", "TCP", "JSON").handlers = [] - factory.get_logger_for_server("example.com", "TCP", "JSON") - actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) - assert actual == FileEventDictToJSONFormatter - - -def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter( - no_priority_syslog_handler, -): - factory.get_logger_for_server("example.com", "TCP", "RAW-JSON").handlers = [] - factory.get_logger_for_server("example.com", "TCP", "RAW-JSON") - actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) - assert actual == FileEventDictToRawJSONFormatter - - -def test_get_logger_for_server_when_called_twice_only_has_one_handler(no_priority_syslog_handler): - factory.get_logger_for_server("example.com", "TCP", "JSON") - logger = factory.get_logger_for_server("example.com", "TCP", "CEF") - assert len(logger.handlers) == 1 - - -def test_get_logger_for_server_uses_no_priority_syslog_handler(no_priority_syslog_handler): - logger = factory.get_logger_for_server("example.com", "TCP", "CEF") - assert logger.handlers[0] == no_priority_syslog_handler - - -def test_get_logger_for_server_constructs_handler_with_expected_args( - mocker, no_priority_syslog_handler, monkeypatch -): - no_priority_syslog_handler_wrapper = mocker.patch( - "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" - ) - no_priority_syslog_handler_wrapper.return_value = None - factory.get_logger_for_server("example.com", "TCP", "CEF") - no_priority_syslog_handler_wrapper.assert_called_once_with( - "example.com", port=514, protocol="TCP" - ) - - -def test_get_logger_for_server_when_hostname_includes_port_constructs_handler_with_expected_args( - mocker, no_priority_syslog_handler -): - no_priority_syslog_handler_wrapper = mocker.patch( - "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" - ) - no_priority_syslog_handler_wrapper.return_value = None - factory.get_logger_for_server("example.com:999", "TCP", "CEF") - no_priority_syslog_handler_wrapper.assert_called_once_with( - "example.com", port=999, protocol="TCP" - ) diff --git a/tests/test_util.py b/tests/test_util.py index e4b321077..bde5c5506 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,7 +1,7 @@ import pytest from code42cli import PRODUCT_NAME -from code42cli.util import does_user_agree, get_url_parts, find_format_width +from code42cli.util import does_user_agree, find_format_width TEST_HEADER = {u"key1": u"Column 1", u"key2": u"Column 10", u"key3": u"Column 100"} @@ -9,18 +9,6 @@ _NAMESPACE = "{}.util".format(PRODUCT_NAME) -def test_get_url_parts_when_given_host_and_port_returns_expected_parts(): - url_str = "www.example.com:123" - parts = get_url_parts(url_str) - assert parts == ("www.example.com", 123) - - -def test_get_url_parts_when_given_host_without_port_returns_expected_parts(): - url_str = "www.example.com" - parts = get_url_parts(url_str) - assert parts == ("www.example.com", None) - - def test_does_user_agree_when_user_says_y_returns_true(mocker): mocker.patch("builtins.input", return_value="y") assert does_user_agree("Test Prompt") From d59753f6667e40a980b23d99875a1dc531f6a479 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 10 Jul 2020 16:16:33 -0500 Subject: [PATCH 087/349] Fix/init sdk before bulk processing (#109) * remove `write-to` and `send-to` and rename `print` to `search` on alerts/security-data cmds * remove unused loggers * update tests * fix case where exception is printed on KeyboardInterrupt and closed pipe. * update readme and changelog * add TCP examples * remove tests for removed func * remove tests for removed loggers * fix bug from change in latest py42 * add note about external tools for more complex requirements * initalize sdk before passing to bulk func so it doesn't get initialized in each separate thread * updated docs --- src/code42cli/cmds/alert_rules.py | 6 ++++-- src/code42cli/cmds/departing_employee.py | 6 ++++-- src/code42cli/cmds/high_risk_employee.py | 12 ++++++++---- src/code42cli/cmds/legal_hold.py | 10 ++++------ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index ca77f5ec5..f2281934a 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -112,7 +112,8 @@ def bulk(state): @read_csv_arg(headers=ALERT_RULES_CSV_HEADERS) @sdk_options def add(state, csv_rows): - row_handler = lambda rule_id, username: _add_user(state.sdk, rule_id, username) + sdk = state.sdk + row_handler = lambda rule_id, username: _add_user(sdk, rule_id, username) run_bulk_process(row_handler, csv_rows, progress_label="Adding users to alert-rules:") @@ -124,7 +125,8 @@ def add(state, csv_rows): @read_csv_arg(headers=ALERT_RULES_CSV_HEADERS) @sdk_options def remove(state, csv_rows): - row_handler = lambda rule_id, username: _remove_user(state.sdk, rule_id, username) + sdk = state.sdk + row_handler = lambda rule_id, username: _remove_user(sdk, rule_id, username) run_bulk_process(row_handler, csv_rows, progress_label="Removing users from alert-rules:") diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index e6a859231..75c85753d 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -62,8 +62,9 @@ def bulk(state): @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @sdk_options def add(state, csv_rows): + sdk = state.sdk row_handler = lambda username, cloud_alias, departure_date, notes: _add_departing_employee( - state.sdk, username, cloud_alias, departure_date, notes + sdk, username, cloud_alias, departure_date, notes ) run_bulk_process( row_handler, csv_rows, progress_label="Adding users to departing employee detection list:" @@ -77,7 +78,8 @@ def add(state, csv_rows): @read_flat_file_arg @sdk_options def remove(state, file_rows): - row_handler = lambda username: _remove_departing_employee(state.sdk, username) + sdk = state.sdk + row_handler = lambda username: _remove_departing_employee(sdk, username) run_bulk_process( row_handler, file_rows, diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 9e0dffeee..5b256346d 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -101,8 +101,9 @@ def bulk(state): @read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) @sdk_options def add(state, csv_rows): + sdk = state.sdk row_handler = lambda username, cloud_alias, risk_tag, notes: _add_high_risk_employee( - state.sdk, username, cloud_alias, risk_tag, notes + sdk, username, cloud_alias, risk_tag, notes ) run_bulk_process( row_handler, csv_rows, progress_label="Adding users to high risk employee detection list:" @@ -116,7 +117,8 @@ def add(state, csv_rows): @read_flat_file_arg @sdk_options def remove(state, file_rows): - row_handler = lambda username: _remove_high_risk_employee(state.sdk, username) + sdk = state.sdk + row_handler = lambda username: _remove_high_risk_employee(sdk, username) run_bulk_process( row_handler, file_rows, @@ -132,7 +134,8 @@ def remove(state, file_rows): @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) @sdk_options def add_risk_tags(state, csv_rows): - row_handler = lambda username, tag: _add_risk_tags(state.sdk, username, tag) + sdk = state.sdk + row_handler = lambda username, tag: _add_risk_tags(sdk, username, tag) run_bulk_process( row_handler, csv_rows, progress_label="Adding risk tags to users:", ) @@ -146,7 +149,8 @@ def add_risk_tags(state, csv_rows): @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) @sdk_options def remove_risk_tags(state, csv_rows): - row_handler = lambda username, tag: _remove_risk_tags(state.sdk, username, tag) + sdk = state.sdk + row_handler = lambda username, tag: _remove_risk_tags(sdk, username, tag) run_bulk_process( row_handler, csv_rows, progress_label="Removing risk tags from users:", ) diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index f69c49b5c..8a76cc003 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -139,9 +139,8 @@ def bulk(state): @read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) @sdk_options def add(state, csv_rows): - row_handler = lambda matter_id, username: _add_user_to_legal_hold( - state.sdk, matter_id, username - ) + sdk = state.sdk + row_handler = lambda matter_id, username: _add_user_to_legal_hold(sdk, matter_id, username) run_bulk_process(row_handler, csv_rows, progress_label="Adding users to legal hold:") @@ -153,9 +152,8 @@ def add(state, csv_rows): @read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) @sdk_options def remove(state, csv_rows): - row_handler = lambda matter_id, username: _remove_user_from_legal_hold( - state.sdk, matter_id, username - ) + sdk = state.sdk + row_handler = lambda matter_id, username: _remove_user_from_legal_hold(sdk, matter_id, username) run_bulk_process(row_handler, csv_rows, progress_label="Removing users from legal hold:") From c003f273310101ad1bd3eaf9bc4fb858bdfaabfb Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 10 Jul 2020 16:29:22 -0500 Subject: [PATCH 088/349] Bugfix/disable ssl errors fix (#110) * fix for --disable-ssl-errors not actually doing anything * update changelog --- CHANGELOG.md | 3 +++ src/code42cli/sdk_client.py | 13 +++++++++++++ tests/test_sdk_client.py | 16 ++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f258b608..65da5341b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - The `print` command on the `security-data` and `alerts` command groups has been replaced with the `search` command. This was a name change only, all other functionality remains the same. + +- A profile created with the `--disable-ssl-errors` flag will now correctly not verify SSL certs when making requests. A warning message is printed + each time the CLI is run with a profile configured this way, as it is not recommended. ### Added diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index 441e3fa86..d461bb1ad 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -1,6 +1,8 @@ import py42.sdk import py42.settings import py42.settings.debug as debug +import requests +from click import secho from py42.exceptions import Py42UnauthorizedError from requests.exceptions import ConnectionError @@ -15,6 +17,17 @@ def create_sdk(profile, is_debug_mode): if is_debug_mode: py42.settings.debug.level = debug.DEBUG + if profile.ignore_ssl_errors == "True": + secho( + "Warning: Profile '{0}' has SSL verification disabled. Adding certificate verification " + "is strongly advised.".format(profile.name), + fg="red", + err=True, + ) + requests.packages.urllib3.disable_warnings( + requests.packages.urllib3.exceptions.InsecureRequestWarning + ) + py42.settings.verify_ssl_certs = False password = profile.get_password() return validate_connection(profile.authority_url, profile.username, password) diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index e897a156e..c1ba621e2 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -28,6 +28,22 @@ def requests_exception(mocker): return mock_exception +def test_create_sdk_when_profile_has_ssl_errors_disabled_sets_py42_setting_and_prints_warning( + profile, mocker, capsys +): + mock_py42 = mocker.patch("code42cli.sdk_client.py42") + profile.ignore_ssl_errors = "True" + sdk = create_sdk(profile, False) + output = capsys.readouterr() + assert mock_py42.settings.verify_ssl_certs == False + assert ( + "Warning: Profile '{0}' has SSL verification disabled. Adding certificate verification is strongly advised.".format( + profile.name + ) + in output.err + ) + + def test_create_sdk_when_py42_exception_occurs_raises_and_logs_cli_error( sdk_logger, mock_sdk_factory, requests_exception ): From 90695bef74e1f422d4fcd87bdd451c29aff3a759 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 14 Jul 2020 12:07:35 -0500 Subject: [PATCH 089/349] prep for 1.0.0 pre-release (#111) * bump version * remove py2 references in CONTRIBUTING.md and remove unused completer file * remove completer from setup.py --- CONTRIBUTING.md | 5 ++--- bin/code42cli_completer | 18 ------------------ setup.py | 1 - src/code42cli/__version__.py | 2 +- 4 files changed, 3 insertions(+), 23 deletions(-) delete mode 100755 bin/code42cli_completer diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9164c4f5..7509a683e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ Document all notable consumer-affecting changes in CHANGELOG.md per principles a ## Tests We use [tox](https://tox.readthedocs.io/en/latest/#) to run the -[pytest](https://docs.pytest.org/) test framework on Python 2.7, 3.5, 3.6, and 3.7. +[pytest](https://docs.pytest.org/) test framework on Python 3.5, 3.6, and 3.7. To run all tests, run this at the root of the repo: @@ -49,11 +49,10 @@ To run the tests on all supported versions of Python in a local dev environment, ```bash $ pip install tox -$ pyenv install 2.7.16 $ pyenv install 3.5.7 $ pyenv install 3.6.9 $ pyenv install 3.7.4 -$ pyenv local 2.7.16 3.5.7 3.6.9 3.7.4 +$ pyenv local 3.5.7 3.6.9 3.7.4 $ tox ``` diff --git a/bin/code42cli_completer b/bin/code42cli_completer deleted file mode 100755 index 3b2873763..000000000 --- a/bin/code42cli_completer +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env python3 -# file inspired from awscli https://github.com/aws/aws-cli/blob/develop/bin/aws_completer - -import os -if os.environ.get('LC_CTYPE', '') == 'UTF-8': - os.environ['LC_CTYPE'] = 'en_US.UTF-8' -import code42cli.completer - -if __name__ == '__main__': - # bash exports COMP_LINE and COMP_POINT, tcsh COMMAND_LINE only - cline = os.environ.get('COMP_LINE') or os.environ.get('COMMAND_LINE') or '' - cpoint = int(os.environ.get('COMP_POINT') or len(cline)) - try: - code42cli.completer.complete(cline, cpoint) - except KeyboardInterrupt: - # If the user hits Ctrl+C, we don't want to print - # a trace - pass diff --git a/setup.py b/setup.py index 77a48c0c8..867f64dac 100644 --- a/setup.py +++ b/setup.py @@ -56,6 +56,5 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", ], - scripts=["bin/code42cli_completer"], entry_points={"console_scripts": ["code42=code42cli.main:cli"]}, ) diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 4910b9ec3..531382306 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "0.7.3" +__version__ = "1.0.0b1" From 1a1133f4f07f790d33b5ba5e32ee3e4f8874dc45 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 16 Jul 2020 07:51:13 -0500 Subject: [PATCH 090/349] Add -y option for bypassing prompts (#112) * add generic `@yes_option` functionality, and apply the option to profile `delete` commands * add tests and move printed message to does_user_agree arg * change wording in changelog * make all text get printed by does_user_agree on `profile delete` so `-y` doesn't output any prompt text * fix test --- CHANGELOG.md | 1 + src/code42cli/cmds/profile.py | 25 +++++++++++++------------ src/code42cli/options.py | 14 +++++++++++++- src/code42cli/util.py | 9 +++++++-- tests/cmds/test_profile.py | 19 ++++++++++++++----- tests/conftest.py | 1 + tests/test_profile.py | 4 ---- tests/test_util.py | 31 ++++++++++++++++++++++++++++--- 8 files changed, 77 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65da5341b..02486210a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added - Profile can now save multiple alert and file event checkpoints. The name of the checkpoint to be used for a given query should be passed to `-c` (`--use-checkpoint`). +- `-y/--assume-yes` option added to `profile delete` and `profile delete-all` commands to not require interactive prompt. ### Removed diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 5ade7ed26..69c5f512f 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -5,6 +5,7 @@ import code42cli.profile as cliprofile from code42cli.errors import Code42CLIError +from code42cli.options import yes_option from code42cli.profile import CREATE_PROFILE_HELP from code42cli.sdk_client import validate_connection from code42cli.util import does_user_agree @@ -103,29 +104,29 @@ def use(profile_name): @profile.command() +@yes_option @profile_name_arg def delete(profile_name): """Deletes a profile and its stored password (if any).""" + message = "\nDeleting this profile will also delete any stored passwords and checkpoints. Are you sure? (y/n): " if cliprofile.is_default_profile(profile_name): - echo("\n{} is currently the default profile!".format(profile_name)) - if not does_user_agree( - "\nDeleting this profile will also delete any stored passwords and checkpoints. " - "Are you sure? (y/n): " - ): - return - cliprofile.delete_profile(profile_name) - echo("Profile '{}' has been deleted.".format(profile_name)) + message = "\n'{0}' is currently the default profile!\n{1}".format(profile_name, message) + if does_user_agree(message): + cliprofile.delete_profile(profile_name) + echo("Profile '{}' has been deleted.".format(profile_name)) @profile.command() +@yes_option def delete_all(): """Deletes all profiles and saved passwords (if any).""" existing_profiles = cliprofile.get_all_profiles() if existing_profiles: - echo("\nAre you sure you want to delete the following profiles?") - for profile in existing_profiles: - echo("\t{}".format(profile.name)) - if does_user_agree("\nThis will also delete any stored passwords and checkpoints. (y/n): "): + message = ( + "\nAre you sure you want to delete the following profiles?\n\t{}" + "\n\nThis will also delete any stored passwords and checkpoints. (y/n): " + ).format("\n\t".join([profile.name for profile in existing_profiles])) + if does_user_agree(message): for profile in existing_profiles: cliprofile.delete_profile(profile.name) echo("Profile '{}' has been deleted.".format(profile.name)) diff --git a/src/code42cli/options.py b/src/code42cli/options.py index c5a8f678a..4efbb2e04 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -6,6 +6,15 @@ from code42cli.profile import get_profile from code42cli.sdk_client import create_sdk +yes_option = click.option( + "-y", + "--assume-yes", + is_flag=True, + expose_value=False, + callback=lambda ctx, param, value: ctx.obj.set_assume_yes(value), + help='Assume "yes" as answer to all prompts and run non-interactively.', +) + class CLIState(object): def __init__(self): @@ -16,7 +25,7 @@ def __init__(self): self.debug = False self._sdk = None self.search_filters = [] - self.cursor_class = None + self.assume_yes = False @property def profile(self): @@ -34,6 +43,9 @@ def sdk(self): self._sdk = create_sdk(self.profile, self.debug) return self._sdk + def set_assume_yes(self, param): + self.assume_yes = param + def set_profile(ctx, param, value): """Sets the profile on the global state object when --profile is passed to commands diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 5561c098c..047b7fbcb 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -5,13 +5,18 @@ from os import path from signal import signal, getsignal, SIGINT -from click import echo, style +from click import echo, style, get_current_context _PADDING_SIZE = 3 def does_user_agree(prompt): - """Prompts the user and checks if they said yes.""" + """Prompts the user and checks if they said yes. If command has the `yes_option` flag, and + `-y/--yes` is passed, this will always return `True`. + """ + ctx = get_current_context() + if ctx.obj.assume_yes: + return True ans = input(prompt) ans = ans.strip().lower() return ans == "y" diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index f376f6ea6..c3661b6e0 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -182,12 +182,10 @@ def test_update_profile_if_user_agrees_and_valid_connection_sets_password( mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) -def test_delete_profile_warns_if_deleting_default( - runner, user_agreement, mock_cliprofile_namespace -): +def test_delete_profile_warns_if_deleting_default(runner, mock_cliprofile_namespace): mock_cliprofile_namespace.is_default_profile.return_value = True result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) - assert "mockdefault is currently the default profile!" in result.output + assert "'mockdefault' is currently the default profile!" in result.output def test_delete_profile_does_nothing_if_user_doesnt_agree( @@ -202,7 +200,7 @@ def test_delete_profile_outputs_success(runner, mock_cliprofile_namespace, user_ assert "Profile 'mockdefault' has been deleted." in result.output -def test_delete_all_warns_if_profiles_exist(runner, user_agreement, mock_cliprofile_namespace): +def test_delete_all_warns_if_profiles_exist(runner, mock_cliprofile_namespace): mock_cliprofile_namespace.get_all_profiles.return_value = [ create_mock_profile("test1"), create_mock_profile("test2"), @@ -213,6 +211,17 @@ def test_delete_all_warns_if_profiles_exist(runner, user_agreement, mock_cliprof assert "test2" in result.output +def test_delete_all_does_not_warn_if_assume_yes_flag(runner, mock_cliprofile_namespace): + mock_cliprofile_namespace.get_all_profiles.return_value = [ + create_mock_profile("test1"), + create_mock_profile("test2"), + ] + result = runner.invoke(cli, ["profile", "delete-all", "-y"]) + assert "Are you sure you want to delete the following profiles?" not in result.output + assert "Profile '{}' has been deleted.".format("test1") in result.output + assert "Profile '{}' has been deleted.".format("test2") in result.output + + def test_delete_all_profiles_does_nothing_if_user_doesnt_agree( runner, user_disagreement, mock_cliprofile_namespace ): diff --git a/tests/conftest.py b/tests/conftest.py index ff3e1d543..c410d577e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,6 +118,7 @@ def cli_state(mocker, sdk, profile): mock_state._sdk = sdk mock_state.profile = profile mock_state.search_filters = [] + mock_state.assume_yes = False return mock_state diff --git a/tests/test_profile.py b/tests/test_profile.py index 9f7362bc5..357187c63 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,8 +1,4 @@ import pytest -import logging - -from click.testing import CliRunner -from code42cli.main import cli import code42cli.profile as cliprofile from code42cli import PRODUCT_NAME diff --git a/tests/test_util.py b/tests/test_util.py index bde5c5506..7320fcb01 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -6,24 +6,49 @@ TEST_HEADER = {u"key1": u"Column 1", u"key2": u"Column 10", u"key3": u"Column 100"} +@pytest.fixture +def context_with_assume_yes(mocker, cli_state): + ctx = mocker.MagicMock() + ctx.obj = cli_state + cli_state.assume_yes = True + return mocker.patch("code42cli.util.get_current_context", return_value=ctx) + + +@pytest.fixture +def context_without_assume_yes(mocker, cli_state): + ctx = mocker.MagicMock() + ctx.obj = cli_state + cli_state.assume_yes = False + return mocker.patch("code42cli.util.get_current_context", return_value=ctx) + + _NAMESPACE = "{}.util".format(PRODUCT_NAME) -def test_does_user_agree_when_user_says_y_returns_true(mocker): +def test_does_user_agree_when_user_says_y_returns_true(mocker, context_without_assume_yes): mocker.patch("builtins.input", return_value="y") assert does_user_agree("Test Prompt") -def test_does_user_agree_when_user_says_capital_y_returns_true(mocker): +def test_does_user_agree_when_user_says_capital_y_returns_true(mocker, context_without_assume_yes): mocker.patch("builtins.input", return_value="Y") assert does_user_agree("Test Prompt") -def test_does_user_agree_when_user_says_n_returns_false(mocker): +def test_does_user_agree_when_user_says_n_returns_false(mocker, context_without_assume_yes): mocker.patch("builtins.input", return_value="n") assert not does_user_agree("Test Prompt") +def test_does_user_agree_when_assume_yes_argument_passed_returns_true_and_does_not_print_prompt( + mocker, context_with_assume_yes, capsys +): + result = does_user_agree("Test Prompt") + output = capsys.readouterr() + assert result + assert output.out == output.err == "" + + def test_find_format_width_when_zero_records_sets_width_to_header_length(): _, column_width = find_format_width([], TEST_HEADER) assert column_width[u"key1"] == len(TEST_HEADER[u"key1"]) From c7a7f61922074a2a6806624dc057991c7dd9cecc Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 16 Jul 2020 11:59:53 -0500 Subject: [PATCH 091/349] Chore/cleanups (#114) * Remove inaccurate unneeded doc * Dont override existing var * Conform prefix like other existing var * Missing period * Unneeded u's * missing space * Remove unused var * Appease possible None * Flake8 no defining lambdas * Rm unused vars * use defs instead of lambdas when defining * Remove unused vars * Imply negation is test name / rm dup test name * Remove unused var * Spelling * More flakey fixes * Remove unused stuff * Fix doc str var * other cleanups --- src/code42cli/cmds/alert_rules.py | 14 +++-- src/code42cli/cmds/departing_employee.py | 14 +++-- src/code42cli/cmds/detectionlists/__init__.py | 3 +- src/code42cli/cmds/high_risk_employee.py | 31 +++++----- src/code42cli/cmds/legal_hold.py | 10 ++-- src/code42cli/cmds/profile.py | 20 +++---- src/code42cli/cmds/search/cursor_store.py | 6 +- src/code42cli/cmds/search/extraction.py | 8 ++- src/code42cli/cmds/search/options.py | 1 - src/code42cli/cmds/securitydata.py | 2 +- src/code42cli/parser.py | 0 tests/cmds/test_alert_rules.py | 16 ++--- tests/cmds/test_alerts.py | 44 +++++++------- tests/cmds/test_departing_employee.py | 12 ++-- tests/cmds/test_high_risk_employee.py | 18 +++--- tests/cmds/test_legal_hold.py | 8 +-- tests/cmds/test_profile.py | 13 ++-- tests/cmds/test_securitydata.py | 60 +++++++++---------- tests/test_config.py | 4 +- tests/test_cursor_store.py | 6 +- tests/test_date_helper.py | 2 - tests/test_logger.py | 3 +- tests/test_sdk_client.py | 2 +- 23 files changed, 146 insertions(+), 151 deletions(-) delete mode 100644 src/code42cli/parser.py diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index f2281934a..fe0e15b8d 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -113,8 +113,9 @@ def bulk(state): @sdk_options def add(state, csv_rows): sdk = state.sdk - row_handler = lambda rule_id, username: _add_user(sdk, rule_id, username) - run_bulk_process(row_handler, csv_rows, progress_label="Adding users to alert-rules:") + def handle_row(rule_id, username): + _add_user(sdk, rule_id, username) + run_bulk_process(handle_row, csv_rows, progress_label="Adding users to alert-rules:") @bulk.command( @@ -126,8 +127,9 @@ def add(state, csv_rows): @sdk_options def remove(state, csv_rows): sdk = state.sdk - row_handler = lambda rule_id, username: _remove_user(sdk, rule_id, username) - run_bulk_process(row_handler, csv_rows, progress_label="Removing users from alert-rules:") + def handle_row(rule_id, username): + _remove_user(sdk, rule_id, username) + run_bulk_process(handle_row, csv_rows, progress_label="Removing users from alert-rules:") def _add_user(sdk, rule_id, username): @@ -136,7 +138,7 @@ def _add_user(sdk, rule_id, username): try: if rules: sdk.alerts.rules.add_user(rule_id, user_id) - except Py42InternalServerError as e: + except Py42InternalServerError: _check_if_system_rule(rules) raise @@ -147,7 +149,7 @@ def _remove_user(sdk, rule_id, username): try: if rules: sdk.alerts.rules.remove_user(rule_id, user_id) - except Py42InternalServerError as e: + except Py42InternalServerError: _check_if_system_rule(rules) raise diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 75c85753d..9195b6eea 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -63,11 +63,12 @@ def bulk(state): @sdk_options def add(state, csv_rows): sdk = state.sdk - row_handler = lambda username, cloud_alias, departure_date, notes: _add_departing_employee( - sdk, username, cloud_alias, departure_date, notes - ) + def handle_row(username, cloud_alias, departure_date, notes): + _add_departing_employee( + sdk, username, cloud_alias, departure_date, notes + ) run_bulk_process( - row_handler, csv_rows, progress_label="Adding users to departing employee detection list:" + handle_row, csv_rows, progress_label="Adding users to departing employee detection list:" ) @@ -79,9 +80,10 @@ def add(state, csv_rows): @sdk_options def remove(state, file_rows): sdk = state.sdk - row_handler = lambda username: _remove_departing_employee(sdk, username) + def handle_row(username): + _remove_departing_employee(sdk, username) run_bulk_process( - row_handler, + handle_row, file_rows, progress_label="Removing users from departing employee detection list:", ) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index ab6658eee..8ca349d7e 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -7,8 +7,7 @@ def update_user(sdk, username, cloud_alias=None, risk_tag=None, notes=None): Args: sdk (py42.sdk.SDKClient): py42 sdk. - user_id (str or unicode): The ID of the user to update. This is their `userUid` found from - `sdk.users.get_by_username()`. + username (str or unicode): The username of the user to update. cloud_alias (str or unicode): A cloud alias to add to the user. risk_tag (iter[str or unicode]): A list of risk tags associated with user. notes (str or unicode): Notes about the user. diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 5b256346d..e203d5e60 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -102,11 +102,12 @@ def bulk(state): @sdk_options def add(state, csv_rows): sdk = state.sdk - row_handler = lambda username, cloud_alias, risk_tag, notes: _add_high_risk_employee( - sdk, username, cloud_alias, risk_tag, notes - ) + def handle_row(username, cloud_alias, risk_tag, notes): + _add_high_risk_employee( + sdk, username, cloud_alias, risk_tag, notes + ) run_bulk_process( - row_handler, csv_rows, progress_label="Adding users to high risk employee detection list:" + handle_row, csv_rows, progress_label="Adding users to high risk employee detection list:" ) @@ -118,9 +119,10 @@ def add(state, csv_rows): @sdk_options def remove(state, file_rows): sdk = state.sdk - row_handler = lambda username: _remove_high_risk_employee(sdk, username) + def handle_row(username): + _remove_high_risk_employee(sdk, username) run_bulk_process( - row_handler, + handle_row, file_rows, progress_label="Removing users from high risk employee detection list:", ) @@ -135,9 +137,10 @@ def remove(state, file_rows): @sdk_options def add_risk_tags(state, csv_rows): sdk = state.sdk - row_handler = lambda username, tag: _add_risk_tags(sdk, username, tag) + def handle_row(username, tag): + _add_risk_tags(sdk, username, tag) run_bulk_process( - row_handler, csv_rows, progress_label="Adding risk tags to users:", + handle_row, csv_rows, progress_label="Adding risk tags to users:", ) @@ -150,9 +153,10 @@ def add_risk_tags(state, csv_rows): @sdk_options def remove_risk_tags(state, csv_rows): sdk = state.sdk - row_handler = lambda username, tag: _remove_risk_tags(sdk, username, tag) + def handle_row(username, tag): + _remove_risk_tags(sdk, username, tag) run_bulk_process( - row_handler, csv_rows, progress_label="Removing risk tags from users:", + handle_row, csv_rows, progress_label="Removing risk tags from users:", ) @@ -169,12 +173,5 @@ def _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes): def _remove_high_risk_employee(sdk, username): - """Removes an employee from the high risk employee detection list. - - Args: - sdk (py42.sdk.SDKClient): py42. - profile (C42Profile): Your code42 profile. - username (str): The username of the employee to remove. - """ user_id = get_user_id(sdk, username) sdk.detectionlists.high_risk_employee.remove(user_id) diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 8a76cc003..8225a7ce1 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -140,8 +140,9 @@ def bulk(state): @sdk_options def add(state, csv_rows): sdk = state.sdk - row_handler = lambda matter_id, username: _add_user_to_legal_hold(sdk, matter_id, username) - run_bulk_process(row_handler, csv_rows, progress_label="Adding users to legal hold:") + def handle_row(matter_id, username): + _add_user_to_legal_hold(sdk, matter_id, username) + run_bulk_process(handle_row, csv_rows, progress_label="Adding users to legal hold:") @bulk.command( @@ -153,8 +154,9 @@ def add(state, csv_rows): @sdk_options def remove(state, csv_rows): sdk = state.sdk - row_handler = lambda matter_id, username: _remove_user_from_legal_hold(sdk, matter_id, username) - run_bulk_process(row_handler, csv_rows, progress_label="Removing users from legal hold:") + def handle_row(matter_id, username): + _remove_user_from_legal_hold(sdk, matter_id, username) + run_bulk_process(handle_row, csv_rows, progress_label="Removing users from legal hold:") def _add_user_to_legal_hold(sdk, matter_id, username): diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 69c5f512f..cf9c96eb7 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -73,10 +73,10 @@ def create(name, server, username, disable_ssl_errors=False): @disable_ssl_option def update(name=None, server=None, username=None, disable_ssl_errors=None): """Update an existing profile.""" - profile = cliprofile.get_profile(name) - cliprofile.update_profile(profile.name, server, username, disable_ssl_errors) - _prompt_for_allow_password_set(profile.name) - echo("Profile '{}' has been updated.".format(profile.name)) + c42profile = cliprofile.get_profile(name) + cliprofile.update_profile(c42profile.name, server, username, disable_ssl_errors) + _prompt_for_allow_password_set(c42profile.name) + echo("Profile '{}' has been updated.".format(c42profile.name)) @profile.command() @@ -92,8 +92,8 @@ def _list(): profiles = cliprofile.get_all_profiles() if not profiles: raise Code42CLIError("No existing profile.", help=CREATE_PROFILE_HELP) - for profile in profiles: - echo(str(profile)) + for c42profile in profiles: + echo(str(c42profile)) @profile.command() @@ -125,11 +125,11 @@ def delete_all(): message = ( "\nAre you sure you want to delete the following profiles?\n\t{}" "\n\nThis will also delete any stored passwords and checkpoints. (y/n): " - ).format("\n\t".join([profile.name for profile in existing_profiles])) + ).format("\n\t".join([c42profile.name for c42profile in existing_profiles])) if does_user_agree(message): - for profile in existing_profiles: - cliprofile.delete_profile(profile.name) - echo("Profile '{}' has been deleted.".format(profile.name)) + for profile_obj in existing_profiles: + cliprofile.delete_profile(profile_obj.name) + echo("Profile '{}' has been deleted.".format(profile_obj.name)) else: echo("\nNo profiles exist. Nothing to delete.") diff --git a/src/code42cli/cmds/search/cursor_store.py b/src/code42cli/cmds/search/cursor_store.py index 7a15a32d3..63e55cb89 100644 --- a/src/code42cli/cmds/search/cursor_store.py +++ b/src/code42cli/cmds/search/cursor_store.py @@ -55,7 +55,7 @@ def clean(self): self.delete(cursor.name) def get_all_cursors(self): - """Returns a list of all cursors stored in this directory (which istypically scoped to a profile).""" + """Returns a list of all cursors stored in this directory (which is typically scoped to a profile).""" dir_contents = os.listdir(self._dir_path) return [Cursor(f) for f in dir_contents if self._is_file(f)] @@ -65,13 +65,13 @@ def _is_file(self, node_name): class FileEventCursorStore(BaseCursorStore): def __init__(self, profile_name): - dir_path = get_user_project_path(u"file_event_checkpoints", profile_name) + dir_path = get_user_project_path("file_event_checkpoints", profile_name) super(FileEventCursorStore, self).__init__(dir_path) class AlertCursorStore(BaseCursorStore): def __init__(self, profile_name): - dir_path = get_user_project_path(u"alert_checkpoints", profile_name) + dir_path = get_user_project_path("alert_checkpoints", profile_name) super(AlertCursorStore, self).__init__(dir_path) diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index 88230bc3a..878a5984f 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -60,10 +60,12 @@ def handle_response(response): except Exception as ex: handlers.handle_error(ex) handlers.TOTAL_EVENTS += len(events) + event = None for event in events: output_logger.info(event) - last_event_timestamp = extractor._get_timestamp_from_item(event) - handlers.record_cursor_position(last_event_timestamp) + if event: + last_event_timestamp = extractor._get_timestamp_from_item(event) + handlers.record_cursor_position(last_event_timestamp) handlers.handle_response = handle_response return handlers @@ -75,6 +77,8 @@ def create_time_range_filter(filter_cls, begin_date=None, end_date=None): `None` if both begin_date and end_date args are `None`. Args: + filter_cls: The class of filter to create. (must be a subclass of + :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) begin_date: The begin date for the range. end_date: The end date for the range. """ diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index 4f05c005b..41539ad58 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -3,7 +3,6 @@ import click -from code42cli.cmds.search.enums import ServerProtocol from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp from code42cli.logger import get_main_cli_logger from code42cli.options import incompatible_with diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index d922611e2..0f59f2f1d 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -129,7 +129,7 @@ def _get_saved_search_query(ctx, param, arg): saved_search_option = click.option( "--saved-search", - help="Get events from a saved search filter with the given ID", + help="Get events from a saved search filter with the given ID.", callback=_get_saved_search_query, cls=incompatible_with("advanced_query"), ) diff --git a/src/code42cli/parser.py b/src/code42cli/parser.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index 2857ab803..948b4d4c2 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -76,7 +76,7 @@ def mock_server_error(mocker): def test_add_user_adds_user_list_to_alert_rules(runner, cli_state): cli_state.sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_USER_ID}]} - result = runner.invoke( + runner.invoke( cli, ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], obj=cli_state, @@ -129,7 +129,7 @@ def test_add_user_when_returns_500_and_not_system_rule_raises_Py42InternalServer def test_remove_user_removes_user_list_from_alert_rules(runner, cli_state): cli_state.sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_USER_ID}]} - result = runner.invoke( + runner.invoke( cli, ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], obj=cli_state, @@ -181,7 +181,7 @@ def test_remove_user_when_returns_500_and_not_system_rule_raises_Py42InternalSer def test_list_gets_alert_rules(runner, cli_state): - result = runner.invoke(cli, ["alert-rules", "list"], obj=cli_state) + runner.invoke(cli, ["alert-rules", "list"], obj=cli_state) assert cli_state.sdk.alerts.rules.get_all.call_count == 1 @@ -193,13 +193,13 @@ def test_list_when_no_rules_prints_no_rules_message(runner, cli_state): def test_show_rule_calls_correct_rule_property(runner, cli_state): cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_GET_ALL_RESPONSE_EXFILTRATION - result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) + runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) cli_state.sdk.alerts.rules.exfiltration.get.assert_called_once_with(TEST_RULE_ID) def test_show_rule_calls_correct_rule_property_cloud_share(runner, cli_state): cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_GET_ALL_RESPONSE_CLOUD_SHARE - result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) + runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) cli_state.sdk.alerts.rules.cloudshare.get.assert_called_once_with(TEST_RULE_ID) @@ -207,7 +207,7 @@ def test_show_rule_calls_correct_rule_property_file_type_mismatch(runner, cli_st cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH ) - result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) + runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) cli_state.sdk.alerts.rules.filetypemismatch.get.assert_called_once_with(TEST_RULE_ID) @@ -223,7 +223,7 @@ def test_add_bulk_users_uses_expected_arguments(runner, mocker, cli_state): with runner.isolated_filesystem(): with open("test_add.csv", "w") as csv: csv.writelines(["rule_id,username\n", "test,value\n"]) - result = runner.invoke(cli, ["alert-rules", "bulk", "add", "test_add.csv"], obj=cli_state) + runner.invoke(cli, ["alert-rules", "bulk", "add", "test_add.csv"], obj=cli_state) assert bulk_processor.call_args[0][1] == [{"rule_id": "test", "username": "value"}] @@ -232,7 +232,7 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: csv.writelines(["rule_id,username\n", "test,value\n"]) - result = runner.invoke( + runner.invoke( cli, ["alert-rules", "bulk", "add", "test_remove.csv"], obj=cli_state ) assert bulk_processor.call_args[0][1] == [{"rule_id": "test", "username": "value"}] diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 912360b30..22915bdf7 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -103,7 +103,7 @@ def test_search_with_advanced_query_uses_only_the_extract_advanced_method( cli_state, alert_extractor, runner ): - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state ) alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') @@ -114,7 +114,7 @@ def test_search_without_advanced_query_uses_only_the_extract_method( cli_state, alert_extractor, runner ): - result = runner.invoke(cli, ["alerts", "search", "--begin", "1d"], obj=cli_state) + runner.invoke(cli, ["alerts", "search", "--begin", "1d"], obj=cli_state) assert alert_extractor.extract.call_count == 1 assert alert_extractor.extract_advanced.call_count == 0 @@ -155,7 +155,7 @@ def test_search_when_given_begin_and_end_dates_uses_expected_query( begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", begin_date, "--end", end_date], obj=cli_state ) filters = alert_extractor.extract.call_args[0][0] @@ -173,7 +173,7 @@ def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) time = "15:33:02" - result = runner.invoke( + runner.invoke( cli, [ "alerts", @@ -213,7 +213,7 @@ def test_search_when_given_end_date_and_time_uses_expected_query( begin_date = get_test_date_str(days_ago=10) end_date = get_test_date_str(days_ago=1) time = "15:33" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], obj=cli_state, @@ -231,10 +231,10 @@ def test_search_when_given_begin_date_more_than_ninety_days_back_errors(cli_stat def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - cli_state, alert_cursor_with_checkpoint, mocker, alert_extractor, runner + cli_state, alert_cursor_with_checkpoint, alert_extractor, runner ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state ) assert not filter_term_is_in_call_args(alert_extractor, DateObserved._term) @@ -244,7 +244,7 @@ def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_u cli_state, alert_extractor, runner ): begin_date = get_test_date_str(days_ago=1) - result = runner.invoke(cli, ["alerts", "search", "--begin", begin_date], obj=cli_state) + runner.invoke(cli, ["alerts", "search", "--begin", begin_date], obj=cli_state) actual_ts = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) expected_ts = "{0}T00:00:00.000Z".format(begin_date) assert actual_ts == expected_ts @@ -264,7 +264,7 @@ def test_search_when_end_date_is_before_begin_date_causes_exit(cli_state, runner def test_get_alert_details_batches_results_according_to_batch_size(sdk): extraction._ALERT_DETAIL_BATCH_SIZE = 2 sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT - results = extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) + extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) assert sdk.alerts.get_details.call_count == 10 @@ -276,9 +276,8 @@ def test_get_alert_details_sorts_results_by_date(sdk): def test_search_with_only_begin_calls_extract_with_expected_filters( - mocker, cli_state, alert_extractor, stdout_logger, begin_option, runner + cli_state, alert_extractor, stdout_logger, begin_option, runner ): - result = runner.invoke( cli, ["alerts", "search", "--begin", ""], obj=cli_state ) @@ -293,7 +292,6 @@ def test_search_with_only_begin_calls_extract_with_expected_filters( def test_search_with_use_checkpoint_and_without_begin_and_without_stored_checkpoint_causes_expected_error( cli_state, alert_cursor_without_checkpoint, runner ): - result = runner.invoke(cli, ["alerts", "search", "--use-checkpoint", "test"], obj=cli_state) assert result.exit_code == 2 assert ( @@ -308,10 +306,8 @@ def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract begin_option, alert_cursor_without_checkpoint, stdout_logger, - mocker, runner, ): - result = runner.invoke( cli, ["alerts", "search", "--use-checkpoint", "test", "--begin", ""], @@ -340,7 +336,7 @@ def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_ca def test_search_when_given_actor_is_uses_username_filter(cli_state, alert_extractor, runner): actor_name = "test.testerson" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--actor", actor_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -350,7 +346,7 @@ def test_search_when_given_actor_is_uses_username_filter(cli_state, alert_extrac def test_search_when_given_exclude_actor_uses_actor_filter(cli_state, alert_extractor, runner): actor_name = "test.testerson" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--exclude-actor", actor_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -360,7 +356,7 @@ def test_search_when_given_exclude_actor_uses_actor_filter(cli_state, alert_extr def test_search_when_given_rule_name_uses_rule_name_filter(cli_state, alert_extractor, runner): rule_name = "departing employee" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--rule-name", rule_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -372,7 +368,7 @@ def test_search_when_given_exclude_rule_name_uses_rule_name_not_filter( ): rule_name = "departing employee" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-name", rule_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -382,7 +378,7 @@ def test_search_when_given_exclude_rule_name_uses_rule_name_not_filter( def test_search_when_given_rule_type_uses_rule_name_filter(cli_state, alert_extractor, runner): rule_type = "FedEndpointExfiltration" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--rule-type", rule_type], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -394,7 +390,7 @@ def test_search_when_given_exclude_rule_type_uses_rule_name_not_filter( ): rule_type = "FedEndpointExfiltration" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-type", rule_type], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -404,7 +400,7 @@ def test_search_when_given_exclude_rule_type_uses_rule_name_not_filter( def test_search_when_given_rule_id_uses_rule_name_filter(cli_state, alert_extractor, runner): rule_id = "departing employee" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--rule-id", rule_id], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -416,7 +412,7 @@ def test_search_when_given_exclude_rule_id_uses_rule_name_not_filter( ): rule_id = "departing employee" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-id", rule_id], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -426,7 +422,7 @@ def test_search_when_given_exclude_rule_id_uses_rule_name_not_filter( def test_search_when_given_description_uses_description_filter(cli_state, alert_extractor, runner): description = "test description" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--description", description], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] @@ -440,7 +436,7 @@ def test_search_when_given_multiple_search_args_uses_expected_filters( exclude_actor = "flag.flagerson@code42.com" rule_name = "departing employee" - result = runner.invoke( + runner.invoke( cli, [ "alerts", diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 382826ae4..315827da4 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -9,7 +9,7 @@ def test_add_departing_employee_when_given_cloud_alias_adds_alias(runner, cli_state_with_user): alias = "departing employee alias" - result = runner.invoke( + runner.invoke( cli, ["departing-employee", "add", _EMPLOYEE, "--cloud-alias", alias], obj=cli_state_with_user, @@ -23,7 +23,7 @@ def test_add_departing_employee_when_given_notes_updates_notes( runner, cli_state_with_user, profile ): notes = "is leaving" - result = runner.invoke( + runner.invoke( cli, ["departing-employee", "add", _EMPLOYEE, "--notes", notes], obj=cli_state_with_user, ) cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) @@ -33,7 +33,7 @@ def test_add_departing_employee_adds( runner, cli_state_with_user, ): departure_date = "2020-02-02" - result = runner.invoke( + runner.invoke( cli, ["departing-employee", "add", _EMPLOYEE, "--departure-date", departure_date], obj=cli_state_with_user, @@ -73,7 +73,7 @@ def test_add_departing_employee_when_bad_request_but_not_user_already_added_rais def test_remove_departing_employee_calls_remove(runner, cli_state_with_user): - result = runner.invoke( + runner.invoke( cli, ["departing-employee", "remove", _EMPLOYEE], obj=cli_state_with_user ) cli_state_with_user.sdk.detectionlists.departing_employee.remove.assert_called_once_with( @@ -108,7 +108,7 @@ def test_add_bulk_users_calls_expected_py42_methods(runner, mocker, cli_state): "test_user_3,,,\n", ] ) - result = runner.invoke( + runner.invoke( cli, ["departing-employee", "bulk", "add", "test_add.csv"], obj=cli_state ) de_add_user_call_args = [call[1] for call in de_add_user.call_args_list] @@ -133,7 +133,7 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state_wit with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: csv.writelines(["# username\n", "test_user1\n", "test_user2\n"]) - result = runner.invoke( + runner.invoke( cli, ["departing-employee", "bulk", "remove", "test_remove.csv"], obj=cli_state_with_user, diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index f0df03850..18d735d1a 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -8,7 +8,7 @@ def test_add_high_risk_employee_adds(runner, cli_state_with_user): - result = runner.invoke(cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + runner.invoke(cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user) cli_state_with_user.sdk.detectionlists.high_risk_employee.add.assert_called_once_with(TEST_ID) @@ -25,7 +25,7 @@ def test_add_high_risk_employee_when_given_cloud_alias_adds_alias(runner, cli_st def test_add_high_risk_employee_when_given_risk_tags_adds_tags(runner, cli_state_with_user): - result = runner.invoke( + runner.invoke( cli, [ "high-risk-employee", @@ -47,7 +47,7 @@ def test_add_high_risk_employee_when_given_risk_tags_adds_tags(runner, cli_state def test_add_high_risk_employee_when_given_notes_updates_notes(runner, cli_state_with_user): notes = "being risky" - result = runner.invoke( + runner.invoke( cli, ["high-risk-employee", "add", _EMPLOYEE, "--notes", notes], obj=cli_state_with_user, ) cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) @@ -85,7 +85,7 @@ def test_add_high_risk_employee_when_bad_request_but_not_user_already_added_exit def test_remove_high_risk_employee_calls_remove(runner, cli_state_with_user): - result = runner.invoke( + runner.invoke( cli, ["high-risk-employee", "remove", _EMPLOYEE], obj=cli_state_with_user ) cli_state_with_user.sdk.detectionlists.high_risk_employee.remove.assert_called_once_with( @@ -104,7 +104,7 @@ def test_remove_high_risk_employee_when_user_does_not_exist_exits_with_correct_m def test_generate_template_file_when_given_add_generates_template_from_handler( - runner, mocker, cli_state + runner, cli_state ): pass @@ -113,7 +113,7 @@ def test_generate_template_file_when_given_remove_generates_template_from_handle pass -def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state, mocker): +def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state): add_user_cloud_alias = thread_safe_side_effect() add_user_risk_tags = thread_safe_side_effect() update_user_notes = thread_safe_side_effect() @@ -134,7 +134,7 @@ def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state, mocke "test_user_3,,,\n", ] ) - result = runner.invoke( + runner.invoke( cli, ["high-risk-employee", "bulk", "add", "test_add.csv"], obj=cli_state ) alias_args = [call[1] for call in add_user_cloud_alias.call_args_list] @@ -171,7 +171,7 @@ def test_bulk_add_risk_tags_uses_expected_arguments(runner, cli_state, mocker): with runner.isolated_filesystem(): with open("test_add_risk_tags.csv", "w") as csv: csv.writelines(["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"]) - result = runner.invoke( + runner.invoke( cli, ["high-risk-employee", "bulk", "add-risk-tags", "test_add_risk_tags.csv"], obj=cli_state, @@ -187,7 +187,7 @@ def test_bulk_remove_risk_tags_uses_expected_arguments(runner, cli_state, mocker with runner.isolated_filesystem(): with open("test_remove_risk_tags.csv", "w") as csv: csv.writelines(["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"]) - result = runner.invoke( + runner.invoke( cli, ["high-risk-employee", "bulk", "remove-risk-tags", "test_remove_risk_tags.csv"], obj=cli_state, diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index 3af3af3ba..de7761d24 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -141,7 +141,7 @@ def test_add_user_raises_legalhold_not_found_error_if_matter_inaccessible( def test_add_user_adds_user_to_hold_if_user_and_matter_exist( runner, cli_state, check_matter_accessible_success, get_user_id_success ): - result = runner.invoke( + runner.invoke( cli, [ "legal-hold", @@ -212,7 +212,7 @@ def test_remove_user_removes_user_if_user_in_matter( membership_uid = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT[0]["legalHoldMemberships"][0][ "legalHoldMembershipUid" ] - result = runner.invoke( + runner.invoke( cli, [ "legal-hold", @@ -336,7 +336,7 @@ def test_add_bulk_users_uses_expected_arguments(runner, mocker, cli_state): with runner.isolated_filesystem(): with open("test_add.csv", "w") as csv: csv.writelines(["matter_id,username\n", "test,value\n"]) - result = runner.invoke(cli, ["legal-hold", "bulk", "add", "test_add.csv"], obj=cli_state) + runner.invoke(cli, ["legal-hold", "bulk", "add", "test_add.csv"], obj=cli_state) assert bulk_processor.call_args[0][1] == [{"matter_id": "test", "username": "value"}] @@ -345,7 +345,7 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: csv.writelines(["matter_id,username\n", "test,value\n"]) - result = runner.invoke( + runner.invoke( cli, ["legal-hold", "bulk", "remove", "test_remove.csv"], obj=cli_state ) assert bulk_processor.call_args[0][1] == [{"matter_id": "test", "username": "value"}] diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index c3661b6e0..11802c276 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -104,7 +104,6 @@ def test_create_profile_if_credentials_invalid_password_not_saved( runner, user_agreement, invalid_connection, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False - success = False result = runner.invoke( cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"], ) @@ -191,7 +190,7 @@ def test_delete_profile_warns_if_deleting_default(runner, mock_cliprofile_namesp def test_delete_profile_does_nothing_if_user_doesnt_agree( runner, user_disagreement, mock_cliprofile_namespace ): - result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) + runner.invoke(cli, ["profile", "delete", "mockdefault"]) assert mock_cliprofile_namespace.delete_profile.call_count == 0 @@ -225,7 +224,7 @@ def test_delete_all_does_not_warn_if_assume_yes_flag(runner, mock_cliprofile_nam def test_delete_all_profiles_does_nothing_if_user_doesnt_agree( runner, user_disagreement, mock_cliprofile_namespace ): - result = runner.invoke(cli, ["profile", "delete-all"]) + runner.invoke(cli, ["profile", "delete-all"]) assert mock_cliprofile_namespace.delete_profile.call_count == 0 @@ -236,7 +235,7 @@ def test_delete_all_deletes_all_existing_profiles( create_mock_profile("test1"), create_mock_profile("test2"), ] - result = runner.invoke(cli, ["profile", "delete-all"]) + runner.invoke(cli, ["profile", "delete-all"]) mock_cliprofile_namespace.delete_profile.assert_any_call("test1") mock_cliprofile_namespace.delete_profile.assert_any_call("test2") @@ -246,7 +245,7 @@ def test_prompt_for_password_reset_if_credentials_valid_password_saved( ): mock_verify.return_value = True mock_cliprofile_namespace.profile_exists.return_value = False - result = runner.invoke(cli, ["profile", "reset-pw"]) + runner.invoke(cli, ["profile", "reset-pw"]) mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) @@ -255,7 +254,7 @@ def test_prompt_for_password_reset_if_credentials_invalid_password_not_saved( ): mock_verify.side_effect = Code42CLIError("Invalid credentials for user") mock_cliprofile_namespace.profile_exists.return_value = False - result = runner.invoke(cli, ["profile", "reset-pw"]) + runner.invoke(cli, ["profile", "reset-pw"]) assert not mock_cliprofile_namespace.set_password.call_count @@ -281,5 +280,5 @@ def test_list_profiles_when_no_profiles_outputs_no_profiles_message( def test_use_profile(runner, mock_cliprofile_namespace, profile): - result = runner.invoke(cli, ["profile", "use", profile.name]) + runner.invoke(cli, ["profile", "use", profile.name]) mock_cliprofile_namespace.switch_default_profile.assert_called_once_with(profile.name) diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 899e35d98..ef9ba7df2 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -56,7 +56,7 @@ def begin_option(mocker): def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( runner, cli_state, file_event_extractor ): - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state ) file_event_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') @@ -64,10 +64,10 @@ def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( assert file_event_extractor.extract_advanced.call_count == 1 -def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( +def test_search_when_is_not_advanced_query_uses_only_the_extract_advanced_method( runner, cli_state, file_event_extractor ): - result = runner.invoke(cli, ["security-data", "search", "--begin", "1d"], obj=cli_state) + runner.invoke(cli, ["security-data", "search", "--begin", "1d"], obj=cli_state) assert file_event_extractor.extract_advanced.call_count == 0 assert file_event_extractor.extract.call_count == 1 @@ -120,7 +120,7 @@ def test_search_with_advanced_query_and_incompatible_argument_errors(runner, arg ("--use-checkpoint", "test"), ], ) -def test_serach_with_saved_search_and_incompatible_argument_errors(runner, arg, cli_state): +def test_search_with_saved_search_and_incompatible_argument_errors(runner, arg, cli_state): result = runner.invoke( cli, ["security-data", "search", "--saved-search", "test_id", *arg], obj=cli_state, ) @@ -133,7 +133,7 @@ def test_search_when_given_begin_and_end_dates_uses_expected_query( ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", begin_date, "--end", end_date], obj=cli_state ) filters = file_event_extractor.extract.call_args[0][1] @@ -151,7 +151,7 @@ def test_search_when_given_begin_and_end_date_and_time_uses_expected_query( begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) time = "15:33:02" - result = runner.invoke( + runner.invoke( cli, [ "security-data", @@ -177,7 +177,7 @@ def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_que ): date = get_test_date_str(days_ago=89) time = "15:33" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "{} {}".format(date, time)], obj=cli_state ) actual = get_filter_value_from_json( @@ -193,7 +193,7 @@ def test_search_when_given_end_date_and_time_uses_expected_query( begin_date = get_test_date_str(days_ago=10) end_date = get_test_date_str(days_ago=1) time = "15:33" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], obj=cli_state, @@ -215,10 +215,10 @@ def test_search_when_given_begin_date_more_than_ninety_days_back_errors( def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - runner, cli_state, file_event_cursor_with_checkpoint, mocker, file_event_extractor + runner, cli_state, file_event_cursor_with_checkpoint, file_event_extractor ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state, @@ -230,7 +230,7 @@ def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_u runner, cli_state, file_event_extractor ): begin_date = get_test_date_str(days_ago=1) - result = runner.invoke(cli, ["security-data", "search", "--begin", begin_date], obj=cli_state) + runner.invoke(cli, ["security-data", "search", "--begin", begin_date], obj=cli_state) actual_ts = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 ) @@ -281,7 +281,6 @@ def test_search_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_ begin_option, file_event_cursor_without_checkpoint, stdout_logger, - mocker, ): result = runner.invoke( cli, ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state @@ -297,7 +296,6 @@ def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_ca file_event_extractor, file_event_cursor_with_checkpoint, stdout_logger, - mocker, ): result = runner.invoke( cli, ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state @@ -320,7 +318,7 @@ def test_search_when_given_invalid_exposure_type_causes_exit(runner, cli_state): def test_search_when_given_username_uses_username_filter(runner, cli_state, file_event_extractor): c42_username = "test@code42.com" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--c42-username", c42_username], obj=cli_state, @@ -331,7 +329,7 @@ def test_search_when_given_username_uses_username_filter(runner, cli_state, file def test_search_when_given_actor_is_uses_username_filter(runner, cli_state, file_event_extractor): actor_name = "test.testerson" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--actor", actor_name], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] @@ -340,7 +338,7 @@ def test_search_when_given_actor_is_uses_username_filter(runner, cli_state, file def test_search_when_given_md5_uses_md5_filter(runner, cli_state, file_event_extractor): md5 = "abcd12345" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--md5", md5], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] @@ -349,7 +347,7 @@ def test_search_when_given_md5_uses_md5_filter(runner, cli_state, file_event_ext def test_search_when_given_sha256_uses_sha256_filter(runner, cli_state, file_event_extractor): sha_256 = "abcd12345" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--sha256", sha_256], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] @@ -358,7 +356,7 @@ def test_search_when_given_sha256_uses_sha256_filter(runner, cli_state, file_eve def test_search_when_given_source_uses_source_filter(runner, cli_state, file_event_extractor): source = "Gmail" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--source", source], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] @@ -367,16 +365,16 @@ def test_search_when_given_source_uses_source_filter(runner, cli_state, file_eve def test_search_when_given_file_name_uses_file_name_filter(runner, cli_state, file_event_extractor): filename = "test.txt" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--file-name", filename], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(FileName.is_in([filename])) in filter_strings -def test_searcg_when_given_file_path_uses_file_path_filter(runner, cli_state, file_event_extractor): +def test_search_when_given_file_path_uses_file_path_filter(runner, cli_state, file_event_extractor): filepath = "C:\\Program Files" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--file-path", filepath], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] @@ -387,7 +385,7 @@ def test_when_given_process_owner_uses_process_owner_filter( runner, cli_state, file_event_extractor ): process_owner = "root" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--process-owner", process_owner], obj=cli_state, @@ -398,7 +396,7 @@ def test_when_given_process_owner_uses_process_owner_filter( def test_when_given_tab_url_uses_process_tab_url_filter(runner, cli_state, file_event_extractor): tab_url = "https://example.com" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--tab-url", tab_url], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] @@ -409,7 +407,7 @@ def test_when_given_exposure_types_uses_exposure_type_is_in_filter( runner, cli_state, file_event_extractor ): exposure_type = "SharedViaLink" - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--type", exposure_type], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] @@ -419,7 +417,7 @@ def test_when_given_exposure_types_uses_exposure_type_is_in_filter( def test_when_given_include_non_exposure_does_not_include_exposure_type_exists( runner, cli_state, file_event_extractor ): - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--begin", "1h", "--include-non-exposure"], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] @@ -429,7 +427,7 @@ def test_when_given_include_non_exposure_does_not_include_exposure_type_exists( def test_when_not_given_include_non_exposure_includes_exposure_type_exists( runner, cli_state, file_event_extractor ): - result = runner.invoke(cli, ["security-data", "search", "--begin", "1h"], obj=cli_state,) + runner.invoke(cli, ["security-data", "search", "--begin", "1h"], obj=cli_state,) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(ExposureType.exists()) in filter_strings @@ -440,7 +438,7 @@ def test_when_given_multiple_search_args_uses_expected_filters( process_owner = "root" c42_username = "test@code42.com" filename = "test.txt" - result = runner.invoke( + runner.invoke( cli, [ "security-data", @@ -482,7 +480,7 @@ def test_when_given_include_non_exposure_and_exposure_types_causes_exit( def test_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( - runner, cli_state, mocker, caplog + runner, cli_state, caplog ): errors.ERRORED = False exception_msg = "Test Exception" @@ -532,7 +530,7 @@ def test_saved_search_calls_extractor_extract_and_saved_search_execute( } query = FileEventQuery.from_dict(search_query) cli_state.sdk.securitydata.savedsearches.get_query.return_value = query - result = runner.invoke( + runner.invoke( cli, ["security-data", "search", "--saved-search", "test_id"], obj=cli_state ) assert file_event_extractor.extract.call_count == 1 @@ -541,11 +539,11 @@ def test_saved_search_calls_extractor_extract_and_saved_search_execute( def test_saved_search_list_calls_get_method(runner, cli_state): - result = runner.invoke(cli, ["security-data", "saved-search", "list"], obj=cli_state) + runner.invoke(cli, ["security-data", "saved-search", "list"], obj=cli_state) assert cli_state.sdk.securitydata.savedsearches.get.call_count == 1 def test_show_detail_calls_get_by_id_method(runner, cli_state): test_id = "test_id" - result = runner.invoke(cli, ["security-data", "saved-search", "show", test_id], obj=cli_state) + runner.invoke(cli, ["security-data", "saved-search", "show", test_id], obj=cli_state) cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with(test_id) diff --git a/tests/test_config.py b/tests/test_config.py index 6f1956f38..5de943642 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -159,7 +159,7 @@ def test_create_profile_when_given_default_name_does_not_create(self, config_par def test_create_profile_when_no_default_profile_sets_default( self, mocker, config_parser_for_create, mock_saver ): - mock_profile = create_mock_profile_object(_TEST_PROFILE_NAME, None, None) + create_mock_profile_object(_TEST_PROFILE_NAME, None, None) mock_internal = create_internal_object(False) setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) @@ -171,7 +171,7 @@ def test_create_profile_when_no_default_profile_sets_default( def test_create_profile_when_has_default_profile_does_not_set_default( self, mocker, config_parser_for_create, mock_saver ): - mock_profile = create_mock_profile_object(_TEST_PROFILE_NAME, None, None) + create_mock_profile_object(_TEST_PROFILE_NAME, None, None) mock_internal = create_internal_object(True, _TEST_PROFILE_NAME) setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) diff --git a/tests/test_cursor_store.py b/tests/test_cursor_store.py index f7c92d10a..521f0addf 100644 --- a/tests/test_cursor_store.py +++ b/tests/test_cursor_store.py @@ -52,7 +52,7 @@ def test_get_when_profile_does_not_exist_returns_none(self, mocker): checkpoint = store.get(CURSOR_NAME) mock_open = mocker.patch("{}.open".format(_NAMESPACE)) mock_open.side_effect = FileNotFoundError - assert checkpoint == None + assert checkpoint is None def test_get_reads_expected_file(self, mock_open): store = AlertCursorStore(PROFILE_NAME) @@ -72,7 +72,7 @@ def test_replace_writes_expected_content(self, mock_open): store = AlertCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) user_path = path.join(path.expanduser("~"), ".code42cli") - expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname") + path.join(user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname") mock_open.return_value.write.assert_called_once_with("123") def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): @@ -139,7 +139,7 @@ def test_replace_writes_expected_content(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) user_path = path.join(path.expanduser("~"), ".code42cli") - expected_path = path.join( + path.join( user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname" ) mock_open.return_value.write.assert_called_once_with("123") diff --git a/tests/test_date_helper.py b/tests/test_date_helper.py index 98c5dab6a..ecbe2b8c3 100644 --- a/tests/test_date_helper.py +++ b/tests/test_date_helper.py @@ -1,7 +1,5 @@ from datetime import datetime -import click - from c42eventextractor.common import convert_datetime_to_timestamp from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp diff --git a/tests/test_logger.py b/tests/test_logger.py index 77c777556..3e69281fd 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,4 +1,3 @@ -import pytest import logging import os from logging.handlers import RotatingFileHandler @@ -46,7 +45,7 @@ class TestCliLogger(object): _logger = CliLogger() - def test_init_creates_user_error_logger_with_expected_handlers(self, mocker): + def test_init_creates_user_error_logger_with_expected_handlers(self): logger = CliLogger() handler_types = [type(h) for h in logger._logger.handlers] assert RotatingFileHandler in handler_types diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index c1ba621e2..6532bb15a 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -33,7 +33,7 @@ def test_create_sdk_when_profile_has_ssl_errors_disabled_sets_py42_setting_and_p ): mock_py42 = mocker.patch("code42cli.sdk_client.py42") profile.ignore_ssl_errors = "True" - sdk = create_sdk(profile, False) + create_sdk(profile, False) output = capsys.readouterr() assert mock_py42.settings.verify_ssl_certs == False assert ( From b86f3dc5d9445c1b7561aa5224be38797cc4b8cd Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 20 Jul 2020 10:03:14 -0500 Subject: [PATCH 092/349] add --password option to `profile create` and `profile update` commands (#113) * add --password option to `profile create` and `profile update` commands * remove unused `_reset_pw()` * remove unused `_reset_pw()` * clarify reset-pw help * fix bad merge and test --- src/code42cli/cmds/profile.py | 47 ++++++++++++++++++++++++----------- src/code42cli/config.py | 2 -- tests/cmds/test_profile.py | 32 ++++++++++++++++++++---- tests/test_config.py | 23 +---------------- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index cf9c96eb7..09dcd6e08 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -22,14 +22,18 @@ def profile(): "-n", "--name", required=True, - type=str, help="The name of the Code42 CLI profile to use when executing this command.", ) server_option = click.option( - "-s", "--server", required=True, type=str, help="The url and port of the Code42 server." + "-s", "--server", required=True, help="The url and port of the Code42 server." ) username_option = click.option( - "-u", "--username", required=True, type=str, help="The username of the Code42 API user." + "-u", "--username", required=True, help="The username of the Code42 API user." +) +password_option = click.option( + "--password", + help="The password for the Code42 API user. If this option is omitted, interactive prompts " + "will be used to obtain the password.", ) disable_ssl_option = click.option( "--disable-ssl-errors", @@ -58,11 +62,15 @@ def show(profile_name): @name_option @server_option @username_option +@password_option @disable_ssl_option -def create(name, server, username, disable_ssl_errors=False): +def create(name, server, username, password, disable_ssl_errors): """Create profile settings. The first profile created will be the default.""" cliprofile.create_profile(name, server, username, disable_ssl_errors) - _prompt_for_allow_password_set(name) + if password: + _set_pw(name, password) + else: + _prompt_for_allow_password_set(name) echo("Successfully created profile '{}'.".format(name)) @@ -70,20 +78,28 @@ def create(name, server, username, disable_ssl_errors=False): @name_option @server_option @username_option +@password_option @disable_ssl_option -def update(name=None, server=None, username=None, disable_ssl_errors=None): +def update(name, server, username, password, disable_ssl_errors): """Update an existing profile.""" c42profile = cliprofile.get_profile(name) cliprofile.update_profile(c42profile.name, server, username, disable_ssl_errors) - _prompt_for_allow_password_set(c42profile.name) + if password: + _set_pw(name, password) + else: + _prompt_for_allow_password_set(c42profile.name) echo("Profile '{}' has been updated.".format(c42profile.name)) @profile.command() @profile_name_arg -def reset_pw(profile_name=None): - """Change the stored password for a profile.""" - _reset_pw(profile_name) +def reset_pw(profile_name): + """\b + Change the stored password for a profile. Only affects what's stored in the local profile, + does not make any changes to the Code42 user account.""" + password = getpass() + _set_pw(profile_name, password) + echo("Password updated for profile '{}'".format(profile_name)) @profile.command("list") @@ -101,6 +117,7 @@ def _list(): def use(profile_name): """Set a profile as the default.""" cliprofile.switch_default_profile(profile_name) + echo("{} has been set as the default profile.".format(profile_name)) @profile.command() @@ -136,15 +153,15 @@ def delete_all(): def _prompt_for_allow_password_set(profile_name): if does_user_agree("Would you like to set a password? (y/n): "): - _reset_pw(profile_name) + password = getpass() + _set_pw(profile_name, password) -def _reset_pw(profile_name): +def _set_pw(profile_name, password): c42profile = cliprofile.get_profile(profile_name) - new_password = getpass() try: - validate_connection(c42profile.authority_url, c42profile.username, new_password) + validate_connection(c42profile.authority_url, c42profile.username, password) except Exception: secho("Password not stored!", bold=True) raise - cliprofile.set_password(new_password, c42profile.name) + cliprofile.set_password(password, c42profile.name) diff --git a/src/code42cli/config.py b/src/code42cli/config.py index c63a02c5c..edd9ccd9f 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -83,7 +83,6 @@ def switch_default_profile(self, new_default_name): raise NoConfigProfileError(new_default_name) self._internal[self.DEFAULT_PROFILE] = new_default_name self._save() - echo("{} has been set as the default profile.".format(new_default_name)) def delete_profile(self, name): """Deletes a profile.""" @@ -149,7 +148,6 @@ def _try_complete_setup(self, profile): return self._save() - echo("Successfully saved profile '{}'.".format(profile.name)) default_profile = self._internal.get(self.DEFAULT_PROFILE) if default_profile is None or default_profile == self.DEFAULT_VALUE: diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 11802c276..9a30c4eb9 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -103,24 +103,45 @@ def test_create_profile_if_user_does_not_agree_does_not_save_password( def test_create_profile_if_credentials_invalid_password_not_saved( runner, user_agreement, invalid_connection, mock_cliprofile_namespace ): + mock_cliprofile_namespace.profile_exists.return_value = False + result = runner.invoke(cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz"],) + assert "Password not stored!" in result.output + assert not mock_cliprofile_namespace.set_password.call_count + + +def test_create_profile_with_password_option_if_credentials_invalid_password_not_saved( + runner, invalid_connection, mock_cliprofile_namespace +): + password = "test_pass" mock_cliprofile_namespace.profile_exists.return_value = False result = runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"], + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--password", password], ) assert "Password not stored!" in result.output assert not mock_cliprofile_namespace.set_password.call_count + assert "Would you like to set a password?" not in result.output def test_create_profile_if_credentials_valid_password_saved( runner, mocker, user_agreement, valid_connection, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False - runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] - ) + runner.invoke(cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz"]) mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) +def test_create_profile_with_password_option_if_credentials_valid_password_saved( + runner, mocker, valid_connection, mock_cliprofile_namespace +): + password = "test_pass" + mock_cliprofile_namespace.profile_exists.return_value = False + result = runner.invoke( + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--password", password], + ) + mock_cliprofile_namespace.set_password.assert_called_once_with(password, mocker.ANY) + assert "Would you like to set a password?" not in result.output + + def test_create_profile_outputs_confirmation( runner, user_agreement, valid_connection, mock_cliprofile_namespace ): @@ -280,5 +301,6 @@ def test_list_profiles_when_no_profiles_outputs_no_profiles_message( def test_use_profile(runner, mock_cliprofile_namespace, profile): - runner.invoke(cli, ["profile", "use", profile.name]) + result = runner.invoke(cli, ["profile", "use", profile.name]) mock_cliprofile_namespace.switch_default_profile.assert_called_once_with(profile.name) + assert "{} has been set as the default profile.".format(profile.name) in result.output diff --git a/tests/test_config.py b/tests/test_config.py index 5de943642..4bd9e4e80 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,10 +1,7 @@ -from __future__ import with_statement +from configparser import ConfigParser import pytest -from configparser import ConfigParser -import logging -from code42cli import PRODUCT_NAME from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection @@ -143,14 +140,6 @@ def test_switch_default_profile_saves(self, config_parser_for_multiple_profiles, accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) assert mock_saver.call_count - def test_switch_default_profile_outputs_confirmation( - self, capsys, config_parser_for_multiple_profiles, mock_saver - ): - accessor = ConfigAccessor(config_parser_for_multiple_profiles) - accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) - output = capsys.readouterr() - assert "set as the default profile" in output.out - def test_create_profile_when_given_default_name_does_not_create(self, config_parser_for_create): accessor = ConfigAccessor(config_parser_for_create) with pytest.raises(Exception): @@ -189,16 +178,6 @@ def test_create_profile_when_not_existing_saves(self, config_parser_for_create, accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) assert mock_saver.call_count - def test_create_profile_when_not_existing_outputs_confirmation( - self, capsys, config_parser_for_create, mock_saver - ): - mock_internal = create_internal_object(False) - setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) - accessor = ConfigAccessor(config_parser_for_create) - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) - output = capsys.readouterr() - assert "Successfully saved" in output.out - def test_update_profile_when_no_profile_exists_raises_exception( self, config_parser_for_multiple_profiles ): From 5e2090cbf5d162dac7aa82a014918c8fce62b19a Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 20 Jul 2020 12:10:45 -0500 Subject: [PATCH 093/349] Integ 1061 test format str to cols (#115) --- CHANGELOG.md | 4 +++ src/code42cli/util.py | 2 +- tests/test_util.py | 69 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02486210a..ed7bfddd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Fixed + +- Bug where `code42 legal-hold show` would error when terminal was too small. + ### Changed - `-i` (`--incremental`) has been removed, use `-c` (`--use-checkpoint`) with a string name for the checkpoint instead. diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 047b7fbcb..1125bd9b1 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -78,7 +78,7 @@ def format_string_list_to_columns(string_list, max_width=None): if not max_width: max_width, _ = shutil.get_terminal_size() column_width = len(max(string_list, key=len)) + _PADDING_SIZE - num_columns = int(max_width / column_width) + num_columns = int(max_width / column_width) or 1 format_string = "{{:<{0}}}".format(column_width) * num_columns batches = [string_list[i : i + num_columns] for i in range(0, len(string_list), num_columns)] padding = ["" for _ in range(num_columns)] diff --git a/tests/test_util.py b/tests/test_util.py index 7320fcb01..7e2e4b5a3 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,7 +1,12 @@ import pytest from code42cli import PRODUCT_NAME -from code42cli.util import does_user_agree, find_format_width +from code42cli.util import ( + does_user_agree, + find_format_width, + format_string_list_to_columns, + _PADDING_SIZE, +) TEST_HEADER = {u"key1": u"Column 1", u"key2": u"Column 10", u"key3": u"Column 100"} @@ -22,9 +27,20 @@ def context_without_assume_yes(mocker, cli_state): return mocker.patch("code42cli.util.get_current_context", return_value=ctx) +@pytest.fixture +def echo_output(mocker): + return mocker.patch("code42cli.util.echo") + + _NAMESPACE = "{}.util".format(PRODUCT_NAME) +def get_expected_row_width(max_col_len, max_width): + col_size = max_col_len + _PADDING_SIZE + num_cols = int(max_width / col_size) or 1 + return col_size * num_cols + + def test_does_user_agree_when_user_says_y_returns_true(mocker, context_without_assume_yes): mocker.patch("builtins.input", return_value="y") assert does_user_agree("Test Prompt") @@ -76,3 +92,54 @@ def test_find_format_width_filters_keys_not_present_in_header(): result, _ = find_format_width(report, header_with_subset_keys) for item in result: assert u"key2" not in item.keys() + + +def test_format_string_list_to_columns_when_given_no_string_list_does_not_echo(echo_output): + format_string_list_to_columns([], None) + format_string_list_to_columns(None, None) + assert not echo_output.call_count + + +def test_format_string_list_to_columns_when_not_given_max_uses_shell_size(mocker, echo_output): + terminal_size = mocker.patch("code42cli.util.shutil.get_terminal_size") + max_width = 30 + terminal_size.return_value = (max_width, None) # Cols, Rows + + columns = ["col1", "col2"] + format_string_list_to_columns(columns) + + printed_row = echo_output.call_args_list[0][0][0] + assert len(printed_row) == get_expected_row_width(4, max_width) + assert printed_row == "col1 col2 " + + +def test_format_string_list_to_columns_when_given_small_max_width_prints_one_column_per_row(echo_output): + max_width = 5 + + columns = ["col1", "col2"] + format_string_list_to_columns(columns, max_width) + + expected_row_width = get_expected_row_width(4, max_width) + printed_row = echo_output.call_args_list[0][0][0] + assert len(printed_row) == expected_row_width + assert printed_row == "col1 " + + printed_row = echo_output.call_args_list[1][0][0] + assert len(printed_row) == expected_row_width + assert printed_row == "col2 " + + +def test_format_string_list_to_columns_uses_width_of_longest_string(echo_output): + max_width = 5 + + columns = ["col1", "col2_that_is_really_long"] + format_string_list_to_columns(columns, max_width) + + expected_row_width = get_expected_row_width(len("col2_that_is_really_long"), max_width) + printed_row = echo_output.call_args_list[0][0][0] + assert len(printed_row) == expected_row_width + assert printed_row == "col1 " + + printed_row = echo_output.call_args_list[1][0][0] + assert len(printed_row) == expected_row_width + assert printed_row == "col2_that_is_really_long " From 23e5c8e544c74f04217ffae99ee6e2143bbbb04f Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Mon, 20 Jul 2020 14:12:51 -0500 Subject: [PATCH 094/349] Chore/linter tox (#116) --- .coveragerc | 2 - .github/ISSUE_TEMPLATE/bug_report.md | 27 ++ .github/ISSUE_TEMPLATE/feature_request.md | 19 ++ .github/workflows/build.yml | 16 +- .github/workflows/docs.yml | 25 ++ .github/workflows/publish.yml | 2 +- .github/workflows/style.yml | 25 ++ .gitignore | 7 - .idea/.gitignore | 0 .idea/code42cli.iml | 19 ++ .pre-commit-config.yaml | 32 ++- .vscode/settings.json | 11 + CHANGELOG.md | 18 +- CONTRIBUTING.md | 199 +++++++------ MANIFEST.in | 5 +- README.md | 42 +-- docs/Makefile | 2 +- docs/_static/custom.css | 2 +- docs/commands/alerts.md | 20 +- docs/commands/highriskemployee.md | 18 +- docs/commands/profile.md | 14 +- docs/commands/securitydata.md | 14 +- docs/conf.py | 5 +- docs/index.md | 12 +- docs/userguides/detectionlists.md | 32 +-- docs/userguides/gettingstarted.md | 10 +- docs/userguides/profile.md | 12 +- docs/userguides/siemexample.md | 49 ++-- integration/__init__.py | 1 + integration/test_alerts.py | 28 +- integration/util.py | 4 +- pyproject.toml | 17 -- run_integration.py | 6 +- setup.cfg | 31 ++ setup.py | 27 +- src/code42cli/bulk.py | 48 ++-- src/code42cli/cmds/alert_rules.py | 38 ++- src/code42cli/cmds/alerts.py | 105 ++++--- src/code42cli/cmds/departing_employee.py | 46 +-- src/code42cli/cmds/detectionlists/__init__.py | 6 +- src/code42cli/cmds/detectionlists/enums.py | 2 +- src/code42cli/cmds/high_risk_employee.py | 69 +++-- src/code42cli/cmds/legal_hold.py | 61 ++-- src/code42cli/cmds/profile.py | 26 +- src/code42cli/cmds/search/cursor_store.py | 10 +- src/code42cli/cmds/search/enums.py | 20 +- src/code42cli/cmds/search/extraction.py | 14 +- src/code42cli/cmds/search/logger_factory.py | 14 +- src/code42cli/cmds/search/options.py | 38 ++- src/code42cli/cmds/securitydata.py | 92 +++--- src/code42cli/cmds/shared.py | 6 +- src/code42cli/config.py | 6 +- src/code42cli/date_helper.py | 15 +- src/code42cli/errors.py | 24 +- src/code42cli/file_readers.py | 10 +- src/code42cli/logger.py | 12 +- src/code42cli/main.py | 6 +- src/code42cli/options.py | 12 +- src/code42cli/password.py | 4 +- src/code42cli/profile.py | 20 +- src/code42cli/sdk_client.py | 5 +- src/code42cli/util.py | 37 ++- src/code42cli/worker.py | 16 +- tests/cmds/conftest.py | 12 +- tests/cmds/test_alert_rules.py | 77 +++-- tests/cmds/test_alerts.py | 264 +++++++++++++----- tests/cmds/test_departing_employee.py | 42 ++- tests/cmds/test_high_risk_employee.py | 82 ++++-- tests/cmds/test_legal_hold.py | 60 ++-- tests/cmds/test_profile.py | 181 ++++++++++-- tests/cmds/test_securitydata.py | 223 ++++++++++----- tests/conftest.py | 15 +- tests/test_bulk.py | 19 +- tests/test_config.py | 55 +++- tests/test_cursor_store.py | 49 ++-- tests/test_date_helper.py | 27 +- tests/test_logger.py | 19 +- tests/test_logger_factory.py | 9 +- tests/test_password.py | 6 +- tests/test_profile.py | 42 ++- tests/test_sdk_client.py | 13 +- tests/test_util.py | 64 +++-- tests/test_worker.py | 7 +- tox.ini | 41 ++- 84 files changed, 1861 insertions(+), 961 deletions(-) delete mode 100644 .coveragerc create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/style.yml create mode 100644 .idea/.gitignore create mode 100644 .idea/code42cli.iml create mode 100644 .vscode/settings.json delete mode 100644 pyproject.toml create mode 100644 setup.cfg diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index e8a405fe9..000000000 --- a/.coveragerc +++ /dev/null @@ -1,2 +0,0 @@ -[run] -source = src \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..96507c893 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Report a problem +title: "[Bug] " +labels: 'bug' +assignees: '' + +--- +### Description + +### Steps to Reproduce + +1. +2. +3. + +### Expected Behavior + + +### Actual Behavior + + +### Basic Information + +- code42cli version: +- python version: +- operating system: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..3368e9398 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for code42cli +title: "[Enhancement] YOUR IDEA!" +labels: enhancement +assignees: '' + +--- + +## Summary + + +## Proposed API + + + +## Intended Use Case + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67af131aa..8af91364a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,12 @@ name: build -on: [pull_request] +on: + push: + branches: + - master + tags: + - v* + pull_request: jobs: build: @@ -17,6 +23,10 @@ jobs: with: python-version: ${{ matrix.python }} - name: Install tox - run: pip install tox==3.14.3 - - name: Run Tox + run: pip install tox==3.17.1 + - name: Run Unit tests run: tox -e py # Run tox using the version of Python in `PATH` + - name: Submit coverage report + uses: codecov/codecov-action@v1.0.7 + with: + file: ./coverage.xml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..b3026b14a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,25 @@ +name: docs + +on: + push: + branches: + - master + tags: + - v* + pull_request: + +jobs: + + docs: + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install tox + run: pip install tox==3.17.1 + - name: Build docs + run: tox -e docs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b065aeb9f..6cda429ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,4 +66,4 @@ jobs: TWINE_USERNAME: '__token__' TWINE_PASSWORD: ${{ secrets.PYPI_ACCESS_TOKEN }} run: | - twine upload dist/* \ No newline at end of file + twine upload dist/* diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml new file mode 100644 index 000000000..dc2b822dd --- /dev/null +++ b/.github/workflows/style.yml @@ -0,0 +1,25 @@ +name: style + +on: + push: + branches: + - master + tags: + - v* + pull_request: + +jobs: + + style: + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install tox + run: pip install tox==3.17.1 + - name: Run style checks + run: tox -e style diff --git a/.gitignore b/.gitignore index a0b076ac3..a18068a9c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,6 @@ .DS_Store -# IDE files -.idea/ -.vscode - -# Database files -*.db - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/.idea/code42cli.iml b/.idea/code42cli.iml new file mode 100644 index 000000000..f13aa5daa --- /dev/null +++ b/.idea/code42cli.iml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9e8b6632..78db37064 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,28 @@ repos: -- repo: https://github.com/ambv/black - rev: stable - hooks: - - id: black - language_version: python3.6 \ No newline at end of file + - repo: https://github.com/asottile/pyupgrade + rev: v2.7.1 + hooks: + - id: pyupgrade + args: ["--py3-plus"] + - repo: https://github.com/asottile/reorder_python_imports + rev: v2.3.0 + hooks: + - id: reorder-python-imports + args: ["--application-directories", "src"] + - repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.3 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-implicit-str-concat + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.1.0 + hooks: + - id: check-byte-order-marker + - id: trailing-whitespace + - id: end-of-file-fixer diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..3914b9b6d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "editor.rulers": [88], + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.nosetestsEnabled": false, + "python.testing.pytestEnabled": true, + "python.linting.flake8Enabled": true, + "python.linting.enabled": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7bfddd2..568d491dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,14 +19,14 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `-i` (`--incremental`) has been removed, use `-c` (`--use-checkpoint`) with a string name for the checkpoint instead. - The code42cli has been migrated to the [click](https://click.palletsprojects.com) framework. This brings: - - BREAKING CHANGE: Commands that accept multiple values for the same option now must have the option flag provided before each value: + - BREAKING CHANGE: Commands that accept multiple values for the same option now must have the option flag provided before each value: use `--option value1 --option value2` instead of `--option value1 value2` (which was previously possible). - Cosmetic changes to error messages, progress bars, and help message formatting. -- The `print` command on the `security-data` and `alerts` command groups has been replaced with the `search` command. +- The `print` command on the `security-data` and `alerts` command groups has been replaced with the `search` command. This was a name change only, all other functionality remains the same. - -- A profile created with the `--disable-ssl-errors` flag will now correctly not verify SSL certs when making requests. A warning message is printed + +- A profile created with the `--disable-ssl-errors` flag will now correctly not verify SSL certs when making requests. A warning message is printed each time the CLI is run with a profile configured this way, as it is not recommended. ### Added @@ -36,7 +36,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Removed -- The `write-to` and `send-to` commands on `security-data` and `alerts` command groups. +- The `write-to` and `send-to` commands on `security-data` and `alerts` command groups. ## 0.7.3 - 2020-06-23 @@ -110,12 +110,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - optional argument `--include-inactive` additionally prints matter memberships that are no longer active. - optional argument `--include-policy` additionally prints out the matter's backup preservation policy in json form. - `bulk` with subcommands: - - `add-user`: that takes a csv file with matter IDs and usernames. + - `add-user`: that takes a csv file with matter IDs and usernames. - `remove-user`: that takes a csv file with matter IDs and usernames. - - `generate-template`: that creates the file templates. + - `generate-template`: that creates the file templates. - `cmd`: with options `add` and `remove`. - `path` - + - Success messages for `profile delete` and `profile update`. - Additional information in the error log file: @@ -127,7 +127,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - A custom error in the error log when you try adding a user to a detection list who is already added. - Graceful handling of keyboard interrupts (ctrl-c) so stack traces aren't printed to console. - Warning message printed when ctrl-c is encountered in the middle of an operation that could cause incorrect checkpoint - state, a second ctrl-c is required to quit while that operation is ongoing. + state, a second ctrl-c is required to quit while that operation is ongoing. - A progress bar that displays during bulk commands. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7509a683e..eb19452b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,59 +1,124 @@ -# Contributing to code42cli +- [Set up your Development environment](#set-up-your-development-environment) + - [macOS](#macos) + - [Windows/Linux](#windowslinux) +- [Run a full build](#run-a-full-build) +- [Coding Style](#coding-style) + - [Style linter](#style-linter) +- [Tests](#tests) + - [Writing tests](#writing-tests) +- [Documentation](#documentation) + - [Generating documentation](#generating-documentation) + - [Performing a test build](#performing-a-test-build) + - [Running the docs locally](#running-the-docs-locally) +- [Changes](#changes) +- [Opening a PR](#opening-a-pr) + +## Set up your Development environment + +The very first thing to do is to fork the code42cli repo, clone it, and make it your working directory! -## Development environment +```bash +git clone https://github.com/myaccount/code42cli +cd code42cli +``` + +To set up your development environment, create a python virtual environment and activate it. This keeps your dependencies sandboxed so that they are unaffected by (and do not affect) other python packages you may have installed. + +### macOS -Install code42cli and its development dependencies. The `-e` option installs py42 in -["editable mode"](https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs). +There are many ways to do this (you can also use the method outlined for Windows/Linux below), but we recommend using [pyenv](https://github.com/pyenv/pyenv). + +Install `pyenv` and `pyenv-virtualenv` via [homebrew](https://brew.sh/): ```bash -$ pip install -e .[dev] +brew install pyenv pyenv-virtualenv ``` -If you are using `zsh`, you may need to escape the brackets. +After installing `pyenv` and `pyenv-virtualenv`, be sure to add the following entries to your `.zshrc` (or `.bashrc` if you are using bash) and restart your shell: + +```bash +eval "$(pyenv init -)" +eval "$(pyenv virtualenv-init -)" +``` -We use [black](https://black.readthedocs.io/en/stable/) to automatically format our code. -After installing dependencies, be sure to run: +Then, create your virtual environment. While code42cli runs on python 3.5+, a 3.6+ version is required for development in order to run all of the unit tests and style checks. ```bash -$ pre-commit install +pyenv install 3.6.10 +pyenv virtualenv 3.6.10 code42cli +pyenv activate code42cli ``` -This will set up a pre-commit hook that will automatically format your code to our desired styles whenever you commit. -It requires python 3.6+ to run, so be sure to have a qualifying python executable in your PATH when you commit. +Use `source deactivate` to exit the virtual environment and `pyenv activate code42cli` to reactivate it. -## General +### Windows/Linux -* Use positional argument specifiers in `str.format()` -* Use syntax and built-in modules that are compatible with both Python 2 and 3. -* Use the `code42cli._internal.compat` module to create abstractions around functionality that differs between 2 and 3. +Install a version of python 3.6 or higher from [python.org](https://python.org). +Next, in a directory somewhere outside the project, create and activate your virtual environment: -## Changes +```bash +python -m venv code42cli +# macOS/Linux +source code42cli/bin/activate +# Windows +.\code42cli\Scripts\Activate +``` -Document all notable consumer-affecting changes in CHANGELOG.md per principles and guidelines at -[Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +To leave the virtual environment, simply use: +```bash +deactivate +``` -## Tests +Next, with your virtual environment activated, install code42cli and its development dependencies. The `-e` option installs code42cli in +["editable mode"](https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs). + +```bash +pip install -e .[dev] +``` + +Open the project in your IDE of choice and change the python environment to +point to your virtual environment, and you should be ready to go! + +## Run a full build -We use [tox](https://tox.readthedocs.io/en/latest/#) to run the -[pytest](https://docs.pytest.org/) test framework on Python 3.5, 3.6, and 3.7. +We use [tox](https://tox.readthedocs.io/en/latest/#) to run our build against Python 3.5, 3.6, 3.7, and 3.8. When run locally, `tox` will run only against the version of python that your virtual envrionment is running, but all versions will be validated against when you [open a PR](#opening-a-pr). -To run all tests, run this at the root of the repo: +To run all the unit tests, do a test build of the documentation, and check that the code meets all style requirements, simply run: ```bash -$ tox +tox ``` +If the full process runs without any errors, your environment is set up correctly! You can also use `tox` to run sub-parts of the build, as explained below. -If you're using a virtual environment, this will only run the tests within that environment/version of Python. -To run the tests on all supported versions of Python in a local dev environment, we recommend using -[pyenv](https://github.com/pyenv/pyenv) and tox in your system (non-virtual) environment: +## Coding Style + +Use syntax and built-in modules that are compatible with Python 3.5+. + +### Style linter + +When you open a PR, after all of the unit tests successfully pass, a series +of style checks will run. See the [pre-commit-config.yaml](.pre-commit-config.yaml) file to see a list of the projects involved in this automation. If your code does not pass the style checks, the PR will not be allowed to merge. Many of the style rules can be corrected automatically by running a simple command once you are satisfied with your change: ```bash -$ pip install tox -$ pyenv install 3.5.7 -$ pyenv install 3.6.9 -$ pyenv install 3.7.4 -$ pyenv local 3.5.7 3.6.9 3.7.4 -$ tox +tox -e style +``` + +This will output a diff of the files that were changed as well a list of files / line numbers / error descriptions for any style problems that need to be corrected manually. Once these have been corrected and re-pushed, the PR checks should pass. + +You can optionally also choose to have these checks / automatic adjustments +occur automatically on each git commit that you make (instead of only when running `tox`.) To do so, install `pre-commit` and install the pre-commit hooks: + +```bash +pip install pre-commit +pre-commit install +``` + +## Tests + +This will also test that the documentation build passes and run the style checks. If you want to _only_ run the unit tests, you can use: + +```bash +$ tox -e py ``` ### Writing tests @@ -65,7 +130,7 @@ a = 4 assert a % 2 == 0 ``` -Use the following naming convention with test methods: +Use the following naming convention with test methods: test\_\[unit_under_test\]\_\[variables_for_the_test\]\_\[expected_state\] @@ -75,61 +140,28 @@ Example: def test_add_one_and_one_equals_two(): ``` -### Adding a new command - -See class documentation on the [Command](src/code42cli/commands.py) class for an explanation of its constructor parameters. - -1. If you are creating a new top-level command, create a new instance of `Command` and add it to the list returned - by `load_commands()` function in `code42cli.main.MainSubcommandLoader`. - -2. If you are creating a new subcommand, find the top-level command that this will be a subcommand of in - `load_commands()` in `code42cli.main.MainSubcommandLoader` and navigate to its subcommand loader's `load_commands()` - Then, add a new instance of `Command` to the list returned. - -3. For commands that actually are executed (rather than just being groups), you will add a `handler` function as a constructor parameter. - This will be the function that you want to execute when your command is run. - * _Positional_ arguments of the handler will automatically become _required_ cli arguments. - * The order that the positional arguments should be entered in on the cli is the same as the order in which they appear in the handler. - * _Keyword_ arguments of the handler will automatically become _optional_ cli arguments - * the cli argument name will be the same as the handler param name except with `_` replaced with `-`, and prefixed with `--` if optional. - - For example, consider the following python function: - - ```python - def handler_example(one, two, three=None, four=None): - pass - ``` - - When the above function is supplied as a `Command`'s `handler` parameter, the result will be a command that can be executed as follows - (assuming `cmd` is the name given to the command): - - ```bash - $ code42 cmd oneval twoval --three threeval --four fourval - ``` +## Documentation -4. To add descriptions to your cli arguments to appear in the help text, your command takes a function as the `arg_customizer` parameter. - The entire [`ArgConfigCollection`](src/code42cli/args.py) that was automatically created is supplied as the only argument to this function - and can be modified by it. See `code42cli.cmds.profile._load_profile_create_descriptions` for an example of this. +Command functions should have accompanying documentation. Documentation is written in markdown and managed in the `docs` folder of this repo. -5. If one of your handler's parameters is named `sdk`, you will automatically get a `--profile` argument available in the cli and the `sdk` parameter - will automatically contain an instance of `py42.sdk.SDKClient` that was created with the given (or default) profile. - - A cli parameter named `--sdk` will _not_ be added in this case. +### Generating documentation -6. If you have an `sdk` parameter, a parameter named `profile` will automatically contain the info of the profile that was used to create the sdk. - - A parameter named `profile` behaves normally if you do not also have a parameter named `sdk`. +code42cli uses [Sphinx](http://www.sphinx-doc.org/) to generate documentation. -7. Each command accepts a `use_single_arg_obj` bool in its constructor. If set to true, this will instead cause the handler to be called with a single object - containing all of the args as attributes, which will be passed to a variable named `args` in your handler. Since your handler will only contain the parameter `args`, - the names of your cli parameters need to built manually in your `arg_customizer` if you use this option. An example of this can be seen in `code42cli.cmds.securitydata.main`. +#### Performing a test build +To simply test that the documentation build without errors, you can run: -## Documentation +```bash +tox -e docs +``` -`code42cli` uses [Sphinx](http://www.sphinx-doc.org/) to generate documentation. +#### Running the docs locally -To build the documentation, run the following from the `docs` directory: +To build and run the documentation locally, run the following from the `docs` directory: ```bash +pip install sphinx recommonmark sphinx_rtd_theme make html ``` @@ -143,3 +175,14 @@ python -m http.server --directory "_build/html" 1337 ``` and then pointing your browser to `localhost:1337`. + +## Changes + +Document all notable consumer-affecting changes in CHANGELOG.md per principles and guidelines at +[Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## Opening a PR + +When you're satisified with your changes, open a PR and fill out the pull request template file. We recommend prefixing the name of your branch and/or PR title with `bugfix`, `chore`, or `feature` to help quickly categorize your change. Your unit tests and other checks will run against all supported python versions when you do this. + +A team member should get in contact with you shortly to help merge your PR to completion and get it ready for a release! diff --git a/MANIFEST.in b/MANIFEST.in index 8a3560439..5b3dd6809 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,4 @@ -include README.md LICENSE.md tox.ini \ No newline at end of file +include CHANGELOG.md +include README.md +include LICENSE.md +include tox.ini diff --git a/README.md b/README.md index 3bbe0efdf..cb63a796d 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ # The Code42 CLI +![Build status](https://github.com/code42/code42cli/workflows/build/badge.svg) +[![codecov.io](https://codecov.io/github/code42/code42cli/coverage.svg?branch=master)](https://codecov.io/github/code42/code42cli?branch=master) +[![versions](https://img.shields.io/pypi/pyversions/code42cli.svg)](https://pypi.org/project/code42cli/) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Documentation Status](https://readthedocs.org/projects/code42cli/badge/?version=latest)](https://clidocs.code42.com/en/latest/?badge=latest) + Use the `code42` command to interact with your Code42 environment. * `code42 security-data` is a CLI tool for extracting AED events. Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a checkpoint (provided you do not change your query). -* `code42 high-risk-employee` is a collection of tools for managing the high risk employee detection list. Similarly, +* `code42 high-risk-employee` is a collection of tools for managing the high risk employee detection list. Similarly, there is `code42 departing-employee`. ## Requirements @@ -28,14 +34,14 @@ First, create your profile: code42 profile create --name MY_FIRST_PROFILE --server example.authority.com --username security.admin@example.com ``` -Your profile contains the necessary properties for logging into Code42 servers. After running `code42 profile create`, +Your profile contains the necessary properties for logging into Code42 servers. After running `code42 profile create`, the program prompts you about storing a password. If you agree, you are then prompted to input your password. -Your password is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that a -password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each +Your password is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that a +password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each time you run a command. -For development purposes, you may need to ignore ssl errors. If you need to do this, use the `--disable-ssl-errors` +For development purposes, you may need to ignore ssl errors. If you need to do this, use the `--disable-ssl-errors` option when creating your profile: ```bash @@ -48,7 +54,7 @@ You can add multiple profiles with different names and the change the default pr code42 profile use MY_SECOND_PROFILE ``` -When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile +When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile instead of the default one. For example, ```bash @@ -64,11 +70,11 @@ code42 profile list ## Security Data and Alerts Using the CLI, you can query for security events and alerts just like in the admin console, but the results are output -to stdout so they can be written to a file or piped out to another process (for sending to an external syslog server, for -example). +to stdout so they can be written to a file or piped out to another process (for sending to an external syslog server, for +example). -The following examples pertain to security events, but can also be used for alerts by replacing `security-data` with +The following examples pertain to security events, but can also be used for alerts by replacing `security-data` with `alerts`: To print events to stdout, do: @@ -130,7 +136,7 @@ To send events to an external server using `netcat` on Linux/Mac: UDP: ```bash -code42 security-data search -b 10d | nc -u syslog.company.com 514 +code42 security-data search -b 10d | nc -u syslog.company.com 514 ``` TCP: @@ -162,10 +168,10 @@ code42 security-data search -b 10d | foreach { $Writer.WriteLine($_); $Writer.Fl Note: For more complex requirements when sending to an external server (SSL, special formatting, etc.), use a dedicated syslog forwarding tool like `rsyslog` or connection tunneling tool like `stunnel`. -If you want to periodically run the same query, but only retrieve the new events each time, use the -`-c/--use-checkpoint` option with a name for your checkpoint. This stores the timestamp of the query's last event to a -file on disk and uses that as the "begin date" timestamp filter on the next query that uses the same checkpoint name. -Checkpoints are stored per profile. +If you want to periodically run the same query, but only retrieve the new events each time, use the +`-c/--use-checkpoint` option with a name for your checkpoint. This stores the timestamp of the query's last event to a +file on disk and uses that as the "begin date" timestamp filter on the next query that uses the same checkpoint name. +Checkpoints are stored per profile. Initial run requires a begin date: ```bash @@ -201,7 +207,7 @@ The search query parameters are as follows: - `--advanced-query` (raw JSON query) You cannot use other query parameters if you use `--advanced-query`. -To learn more about acceptable arguments, add the `-h` flag to `code42 security-data` +To learn more about acceptable arguments, add the `-h` flag to `code42 security-data` Saved Searches: @@ -234,14 +240,14 @@ code42 high-risk-employee add user@example.com --notes "These are notes" code42 high-risk-employee remove user@example.com ``` -Detection lists include a `bulk` command. To add employees to a list, you can pass in a csv file. First, generate the +Detection lists include a `bulk` command. To add employees to a list, you can pass in a csv file. First, generate the csv file for the desired command by executing the `generate-template` command: ```bash code42 high-risk-employee bulk generate-template add ``` -Notice that `generate-template` takes a `cmd` parameter for determining what type of template to generate. In the +Notice that `generate-template` takes a `cmd` parameter for determining what type of template to generate. In the example above, we give it the value `add` to generate a file for bulk adding users to the high risk employee list. Next, fill out the csv file with all the users and then pass it in as a parameter to `bulk add`: @@ -254,7 +260,7 @@ Note that for `bulk remove`, the file only has to be an end-line delimited list ## Known Issues -In `security-data`, only the first 10,000 of each set of events containing the exact same insertion timestamp is +In `security-data`, only the first 10,000 of each set of events containing the exact same insertion timestamp is reported. ## Troubleshooting diff --git a/docs/Makefile b/docs/Makefile index 298ea9e21..51285967a 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -16,4 +16,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 53f0b627c..550a683db 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,3 +1,3 @@ .wy-side-nav-search>div.version { color: #404040; -} \ No newline at end of file +} diff --git a/docs/commands/alerts.md b/docs/commands/alerts.md index 7ce9408d7..2fb03aac0 100644 --- a/docs/commands/alerts.md +++ b/docs/commands/alerts.md @@ -5,32 +5,32 @@ Search for alerts and print them to stdout. Arguments: -* `advanced-query`: A raw JSON alerts query. Useful for when the provided query parameters do not satisfy your +* `advanced-query`: A raw JSON alerts query. Useful for when the provided query parameters do not satisfy your requirements. WARNING: Using advanced queries is incompatible with other query-building args. -* `-b`, `--begin`: The beginning of the date range in which to look for alerts, can be a date/time in yyyy-MM-dd (UTC) - or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' portion of the string can be partial - (e.g. '2020-01-01 12' or '2020-01-01 01:15') or a short value representing days (30d), hours (24h) or minutes (15m) +* `-b`, `--begin`: The beginning of the date range in which to look for alerts, can be a date/time in yyyy-MM-dd (UTC) + or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' portion of the string can be partial + (e.g. '2020-01-01 12' or '2020-01-01 01:15') or a short value representing days (30d), hours (24h) or minutes (15m) from current time. * `-e`, `--end`: The end of the date range in which to look for alerts, argument format options are the same as --begin. -* `--severity`: Filter alerts by severity. Defaults to returning all severities. +* `--severity`: Filter alerts by severity. Defaults to returning all severities. Available choices=['HIGH', 'MEDIUM', 'LOW'] * `--state`: Filter alerts by state. Defaults to returning all states. Available choices=['OPEN', 'RESOLVED']. -* `--actor`: Filter alerts by including the given actor(s) who triggered the alert. Args must match actor username +* `--actor`: Filter alerts by including the given actor(s) who triggered the alert. Args must match actor username exactly. * `--actor-contains`: Filter alerts by including actor(s) whose username contains the given string. -* `--exclude-actor`: Filter alerts by excluding the given actor(s) who triggered the alert. Args must match actor +* `--exclude-actor`: Filter alerts by excluding the given actor(s) who triggered the alert. Args must match actor username exactly. * `--exclude-actor-contains`: Filter alerts by excluding actor(s) whose username contains the given string. * `--rule-name`: Filter alerts by including the given rule name(s). * `--exclude-rule-name`: Filter alerts by excluding the given rule name(s). * `--rule-id`: Filter alerts by including the given rule id(s). * `--exclude-rule-id`: Filter alerts by excluding the given rule id(s). -* `--rule-type`: Filter alerts by including the given rule type(s). +* `--rule-type`: Filter alerts by including the given rule type(s). Available choices=['FedEndpointExfiltration', 'FedCloudSharePermissions', 'FedFileTypeMismatch']. -* `--exclude-rule-type`: Filter alerts by excluding the given rule type(s). +* `--exclude-rule-type`: Filter alerts by excluding the given rule type(s). Available choices=['FedEndpointExfiltration', 'FedCloudSharePermissions', 'FedFileTypeMismatch']. * `--description`: Filter alerts by description. Does fuzzy search by default. -* `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. +* `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. * `-c`, `--use-checkpoint` (optional): Get only file events that were not previously retrieved by writing the timestamp of the last event retrieved to a named checkpoint. Usage: diff --git a/docs/commands/highriskemployee.md b/docs/commands/highriskemployee.md index 30d11d6e6..63428d82e 100644 --- a/docs/commands/highriskemployee.md +++ b/docs/commands/highriskemployee.md @@ -2,13 +2,13 @@ ## add -Add a user to the high-risk-employee detection list. +Add a user to the high-risk-employee detection list. Arguments: * `username`: A Code42 username for an employee. * `--cloud-alias` (optional): An alternative email address for another cloud service. -* `-risk-tag` (optional): Risk tags associated with the user. Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, - ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, +* `-risk-tag` (optional): Risk tags associated with the user. Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, + ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, CONTRACT_EMPLOYEE]. * `--notes` (optional): Notes about the employee. @@ -35,10 +35,10 @@ Associates risk tags with a user. Arguments: * `--username`, `-u`: A Code42 username for an employee. -* `--tag`: Risk tags associated with the employee. - Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, +* `--tag`: Risk tags associated with the employee. + Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, CONTRACT_EMPLOYEE]. - + Usage: ```bash code42 high-risk-employee add-risk-tags --username --tag @@ -50,10 +50,10 @@ Disassociates risk tags from a user. Arguments: * `--username`, `-u`: A Code42 username for an employee. -* `--tag`: Risk tags associated with the employee. - Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, +* `--tag`: Risk tags associated with the employee. + Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, CONTRACT_EMPLOYEE]. - + Usage: ```bash code42 high-risk-employee remove-risk-tags --username --tag diff --git a/docs/commands/profile.md b/docs/commands/profile.md index c1dcf741f..215b2dbe5 100644 --- a/docs/commands/profile.md +++ b/docs/commands/profile.md @@ -1,7 +1,7 @@ # Profile Commands ## show - + Print the details of a profile. Arguments: @@ -11,7 +11,7 @@ Usage: ```bash code42 profile show ``` - + ## list Show all existing stored profiles. @@ -32,7 +32,7 @@ Usage: ```bash code42 profile use ``` - + ## reset-pw Change the stored password for a profile. @@ -47,13 +47,13 @@ code42 profile reset-pw ## create -Create profile settings. The first profile created will be the default. +Create profile settings. The first profile created will be the default. Arguments: * `--name`, `-n`: The name of the code42cli profile to use when executing this command. * `--server`, `-s`: The url and port of the Code42 server. * `--username`, `-u`: The username of the Code42 API user. -* `--disable-ssl-errors` (optional): For development purposes, do not validate the SSL certificates of Code42 servers. +* `--disable-ssl-errors` (optional): For development purposes, do not validate the SSL certificates of Code42 servers. This is not recommended unless it is required. Usage: @@ -63,13 +63,13 @@ code42 profile create --name --server --username -d +code42 -d ``` ### File an issue on GitHub If you are experiencing an issue with the Code42 CLI, you can create a *New issue* at the -[project repository](https://github.com/code42/code42cli/issues). See the Github +[project repository](https://github.com/code42/code42cli/issues). See the Github [guide on creating an issue](https://help.github.com/en/github/managing-your-work-on-github/creating-an-issue) for more information. ### Contact Code42 Support diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md index e7458e822..4a0da4823 100644 --- a/docs/userguides/profile.md +++ b/docs/userguides/profile.md @@ -1,18 +1,18 @@ # Configure profile -Use the [code42 profile](../commands/profile.md) set of commands to establish the Code42 environment you're working -within and your user information. +Use the [code42 profile](../commands/profile.md) set of commands to establish the Code42 environment you're working +within and your user information. First, create your profile: ```bash code42 profile create --name MY_FIRST_PROFILE --server example.authority.com --username security.admin@example.com ``` -Your profile contains the necessary properties for logging into Code42 servers. After running `code42 profile create`, +Your profile contains the necessary properties for logging into Code42 servers. After running `code42 profile create`, the program prompts you about storing a password. If you agree, you are then prompted to input your password. -Your password is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that a -password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each +Your password is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that a +password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each time you run a command. You can add multiple profiles with different names and the change the default profile with the `use` command: @@ -21,7 +21,7 @@ You can add multiple profiles with different names and the change the default pr code42 profile use MY_SECOND_PROFILE ``` -When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile +When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile instead of the default one. For example, ```bash diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index bcfd76add..2daae6d1d 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -1,44 +1,44 @@ # Integrating with SIEM Tools -The Code42 command-line interface (CLI) tool offers a way to interact with your Code42 environment without using the -Code42 console or making API calls directly. This article provides instructions on using the CLI to extract Code42 data -for use in a security information and event management (SIEM) tool like LogRhythm, Sumo Logic, or IBM QRadar. +The Code42 command-line interface (CLI) tool offers a way to interact with your Code42 environment without using the +Code42 console or making API calls directly. This article provides instructions on using the CLI to extract Code42 data +for use in a security information and event management (SIEM) tool like LogRhythm, Sumo Logic, or IBM QRadar. -You can also use the Code42 CLI to bulk-add or remove users from the High Risk Employees list or Departing Employees -list. For more information, see Manage detection list users with the Code42 command-line interface. +You can also use the Code42 CLI to bulk-add or remove users from the High Risk Employees list or Departing Employees +list. For more information, see Manage detection list users with the Code42 command-line interface. ## Considerations -To integrate with a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration -must be assigned roles that provide the necessary permissions. We recommend you assign the roles in our use case for +To integrate with a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration +must be assigned roles that provide the necessary permissions. We recommend you assign the roles in our use case for managing a security application integrated with Code42. ## Before you begin -To integrate Code42 with a SIEM tool, you must first install and configure the Code42 CLI following the instructions in -[Getting Started](gettingstarted.md) the Code42 command-line interface. +To integrate Code42 with a SIEM tool, you must first install and configure the Code42 CLI following the instructions in +[Getting Started](gettingstarted.md) the Code42 command-line interface. -## Commands and query parameters -You can get security events in either a JSON or CEF format for use by your SIEM tool. You can query the data as a +## Commands and query parameters +You can get security events in either a JSON or CEF format for use by your SIEM tool. You can query the data as a scheduled job or run ad-hoc queries. Learn more about [searching](../commands/securitydata.md) using the CLI. ## Run a query as a scheduled job -Use your favorite scheduling tool, such as cron or Windows Task Scheduler, to run a query on a regular basis. Specify -the profile to use by including `--profile`. An example using `netcat` to forward results to an external syslog server: +Use your favorite scheduling tool, such as cron or Windows Task Scheduler, to run a query on a regular basis. Specify +the profile to use by including `--profile`. An example using `netcat` to forward results to an external syslog server: ```bash -code42 security-data search --profile profile1 -c syslog_sender | nc syslog.example.com 514 +code42 security-data search --profile profile1 -c syslog_sender | nc syslog.example.com 514 ``` -Note that it is best practice to use a separate profile when executing a scheduled task. This way, it is harder to +Note that it is best practice to use a separate profile when executing a scheduled task. This way, it is harder to accidentally mess up your stored checkpoints by running `--use-checkpoint` in adhoc queries. This query will send to the syslog server only the new security event data since the previous request. ## Run an ad-hoc query -Examples of ad-hoc queries you can run are as follows. +Examples of ad-hoc queries you can run are as follows. Print security data since March 5 for a user in raw JSON format: @@ -46,18 +46,18 @@ Print security data since March 5 for a user in raw JSON format: code42 security-data search -f RAW-JSON -b 2020-03-05 --c42-username 'sean.cassidy@example.com' ``` -Print security events since March 5 where a file was synced to a cloud service: +Print security events since March 5 where a file was synced to a cloud service: ```bash -code42 security-data search -t CloudStorage -b 2020-03-05 +code42 security-data search -t CloudStorage -b 2020-03-05 ``` -Write to a text file security events in raw JSON format where a file was read by browser or other app for a user since -March 5: +Write to a text file security events in raw JSON format where a file was read by browser or other app for a user since +March 5: ```bash code42 security-data search -f RAW-JSON -b 2020-03-05 -t ApplicationRead --c42-username 'sean.cassidy@example.com' > /Users/sangita.maskey/Downloads/c42cli_output.txt ``` -Example output for a single exposure event (in default JSON format): +Example output for a single exposure event (in default JSON format): ```json { @@ -101,8 +101,8 @@ The following tables map the data from the Code42 CLI to common event format (CE ### Attribute mapping -The table below maps JSON fields, CEF fields, and [Forensic Search fields](https://support.code42.com/Administrator/Cloud/Administration_console_reference/Forensic_Search_reference_guide) -to one another. +The table below maps JSON fields, CEF fields, and [Forensic Search fields](https://support.code42.com/Administrator/Cloud/Administration_console_reference/Forensic_Search_reference_guide) +to one another. ```eval_rst @@ -183,7 +183,7 @@ to one another. ### Event mapping -See the table below to map exfiltration events to CEF signature IDs. +See the table below to map exfiltration events to CEF signature IDs. ```eval_rst @@ -201,4 +201,3 @@ See the table below to map exfiltration events to CEF signature IDs. | EMAILED | C42204 | +--------------------+-----------+ ``` - diff --git a/integration/__init__.py b/integration/__init__.py index 83afd82fc..469c2f3e8 100644 --- a/integration/__init__.py +++ b/integration/__init__.py @@ -1,4 +1,5 @@ import os + import pexpect diff --git a/integration/test_alerts.py b/integration/test_alerts.py index bb6369ed6..88a2ada3b 100644 --- a/integration/test_alerts.py +++ b/integration/test_alerts.py @@ -1,6 +1,6 @@ -import pytest import json +import pytest from integration import run_command from integration.util import cleanup_after_validation @@ -23,8 +23,16 @@ def _validate_field_value(field, value, response): [ ("{} --state OPEN".format(ALERT_COMMAND), "state", "OPEN"), ("{} --state RESOLVED".format(ALERT_COMMAND), "state", "RESOLVED"), - ("{} --actor spatel@code42.com".format(ALERT_COMMAND), "actor", "spatel@code42.com"), - ("{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), "name", "File Upload Alert"), + ( + "{} --actor spatel@code42.com".format(ALERT_COMMAND), + "actor", + "spatel@code42.com", + ), + ( + "{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), + "name", + "File Upload Alert", + ), ( "{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND), "ruleId", @@ -42,9 +50,11 @@ def _validate_field_value(field, value, response): ), ], ) -def test_alert_prints_to_stdout_and_filters_result_by_given_value(command, field, value): +def test_alert_prints_to_stdout_and_filters_result_by_given_value( + command, field, value +): return_code, response = run_command(command) - assert return_code is 0 + assert return_code == 0 _validate_field_value(field, value, response) @@ -55,10 +65,12 @@ def _validate_begin_date(response): assert record["createdAt"].startswith("2020-05-18") -@pytest.mark.parametrize("command, validate", [(ALERT_COMMAND, _validate_begin_date),]) -def test_alert_prints_to_stdout_and_filters_result_between_given_date(command, validate): +@pytest.mark.parametrize("command, validate", [(ALERT_COMMAND, _validate_begin_date)]) +def test_alert_prints_to_stdout_and_filters_result_between_given_date( + command, validate +): return_code, response = run_command(command) - assert return_code is 0 + assert return_code == 0 validate(response) diff --git a/integration/util.py b/integration/util.py index a0e0c90d3..74dd7afc9 100644 --- a/integration/util.py +++ b/integration/util.py @@ -1,12 +1,12 @@ import os -class cleanup(object): +class cleanup: def __init__(self, filename): self.filename = filename def __enter__(self): - return open(self.filename, "r") + return open(self.filename) def __exit__(self, exc_type, exc_val, exc_tb): os.remove(self.filename) diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 3a5bff861..000000000 --- a/pyproject.toml +++ /dev/null @@ -1,17 +0,0 @@ -[tool.black] -line-length = 100 -include = '\.pyi?$' -exclude = ''' -/( - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist -)/ -''' \ No newline at end of file diff --git a/run_integration.py b/run_integration.py index 26414a7cb..da21ee8a5 100644 --- a/run_integration.py +++ b/run_integration.py @@ -1,5 +1,5 @@ -import sys import os +import sys if __name__ == "__main__": if sys.argv[1] and sys.argv[2]: @@ -8,4 +8,6 @@ rc = os.system("pytest ./integration -v -rsxX -l --tb=short --strict") sys.exit(rc) else: - print("username and password were not supplied. Integration tests will be skipped.") + print( + "username and password were not supplied. Integration tests will be skipped." + ) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..43fd0e55b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,31 @@ +[metadata] +license_file = LICENSE.md + +[bdist_wheel] +universal = 1 + +[tool:pytest] +testpaths = tests +filterwarnings = + error + +[flake8] +# B = bugbear +# E = pycodestyle errors +# F = flake8 pyflakes +# W = pycodestyle warnings +# B9 = bugbear opinions, +# ISC = implicit str concat +select = B, E, F, W, B9, ISC +ignore = + # slice notation whitespace, different opinion from black + E203 + # line length, handled by black + B950 + E501 + # bare except, handled by bugbear B001 + E722 + # binary operation line break, different opinion from black + W503 +# up to 88 allowed by bugbear B950 +max-line-length = 80 diff --git a/setup.py b/setup.py index 867f64dac..be32cc3f9 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,8 @@ from codecs import open from os import path -from setuptools import find_packages, setup + +from setuptools import find_packages +from setuptools import setup here = path.abspath(path.dirname(__file__)) @@ -14,11 +16,19 @@ setup( name="code42cli", version=about["__version__"], + url="https://github.com/code42/py42", + project_urls={ + "Issue Tracker": "https://github.com/code42/code42cli/issues", + "Documentation": "https://clidocs.code42.com/", + "Source Code": "https://github.com/code42/code42cli", + }, description="The official command line tool for interacting with Code42", long_description=readme, long_description_content_type="text/markdown", packages=find_packages("src"), package_dir={"": "src"}, + include_package_data=True, + zip_safe=False, python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", install_requires=[ "click>=7.1.1", @@ -28,20 +38,13 @@ "keyrings.alt==3.2.0", "py42>=1.5.1", ], - license="MIT", - include_package_data=True, - zip_safe=False, extras_require={ "dev": [ - "pre-commit", - "pytest==4.6.5", - "pytest-cov == 2.8.1", + "flake8==3.8.3", + "pytest==4.6.11", + "pytest-cov==2.10.0", "pytest-mock==2.0.0", - "recommonmark", - "sphinx", - "sphinx_rtd_theme", - "tox==3.14.3", - "pexpect>=4.8", + "tox>=3.17.1", ] }, classifiers=[ diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index e829d01d2..6c574dc52 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -9,7 +9,7 @@ _logger = get_main_cli_logger() -class BulkCommandType(object): +class BulkCommandType: ADD = "add" REMOVE = "remove" @@ -31,14 +31,14 @@ def write_template_file(path, columns=None, flat_item=None): def generate_template_cmd_factory(group_name, commands_dict): """Helper function that creates a `generate-template` click command that can be added to `bulk` - sub-command groups. - - Args: + sub-command groups. + + Args: `group_name`: a str representing the parent command group this is generating templates for. - `commands_dict`: a dict of the commands with their column names. Keys are the cmd - names that will become the `cmd` argument, and values are the list of column names for + `commands_dict`: a dict of the commands with their column names. Keys are the cmd + names that will become the `cmd` argument, and values are the list of column names for the csv. - + If a cmd takes a flat file, value should be a string indicating what item the flat file rows should contain. """ @@ -46,12 +46,14 @@ def generate_template_cmd_factory(group_name, commands_dict): @click.command() @click.argument("cmd", type=click.Choice(list(commands_dict))) @click.argument( - "path", required=False, type=click.Path(dir_okay=False, resolve_path=True, writable=True) + "path", + required=False, + type=click.Path(dir_okay=False, resolve_path=True, writable=True), ) def generate_template(cmd, path): """\b Generate the csv template needed for bulk adding/removing users. - + Optional PATH argument can be provided to write to a specific file path/name. """ columns = commands_dict[cmd] @@ -68,9 +70,9 @@ def generate_template(cmd, path): def run_bulk_process(row_handler, rows, progress_label=None): """Runs a bulk process. - - Args: - row_handler (callable): A callable that you define to process values from the row as + + Args: + row_handler (callable): A callable that you define to process values from the row as either *args or **kwargs. rows (iterable): the rows to process. """ @@ -83,14 +85,14 @@ def _create_bulk_processor(row_handler, rows, progress_label): return BulkProcessor(row_handler, rows, progress_label=progress_label) -class BulkProcessor(object): - """A class for bulk processing a file. - +class BulkProcessor: + """A class for bulk processing a file. + Args: - row_handler (callable): A callable that you define to process values from the row as - either *args or **kwargs. For example, if it's a csv file with header `prop_a,prop_b` - and first row `1,test`, then `row_handler` should receive kwargs - `prop_a: '1', prop_b: 'test'` when processing the first row. If it's a flat file, then + row_handler (callable): A callable that you define to process values from the row as + either *args or **kwargs. For example, if it's a csv file with header `prop_a,prop_b` + and first row `1,test`, then `row_handler` should receive kwargs + `prop_a: '1', prop_b: 'test'` when processing the first row. If it's a flat file, then `row_handler` only needs to take an extra arg. reader (CSVReader or FlatFileReader): A generator that reads rows and yields data into `row_handler`. """ @@ -100,7 +102,9 @@ def __init__(self, row_handler, rows, worker=None, progress_label=None): self._rows = rows self._row_handler = row_handler self._progress_bar = click.progressbar( - length=len(self._rows), item_show_func=self._show_stats, label=progress_label + length=len(self._rows), + item_show_func=self._show_stats, + label=progress_label, ) self.__worker = worker or Worker(5, total, bar=self._progress_bar) self._stats = self.__worker.stats @@ -130,7 +134,9 @@ def _process_csv_row(self, row): def _process_flat_file_row(self, row): if row: - self.__worker.do_async(lambda *args, **kwargs: self._handle_row(*args, **kwargs), row) + self.__worker.do_async( + lambda *args, **kwargs: self._handle_row(*args, **kwargs), row + ) def _handle_row(self, *args, **kwargs): self._row_handler(*args, **kwargs) diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index fe0e15b8d..b83e08022 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -9,13 +9,16 @@ from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process from code42cli.cmds.shared import get_user_id -from code42cli.errors import Code42CLIError, InvalidRuleTypeError +from code42cli.errors import Code42CLIError +from code42cli.errors import InvalidRuleTypeError from code42cli.file_readers import read_csv_arg -from code42cli.options import sdk_options, OrderedGroup -from code42cli.util import format_to_table, find_format_width +from code42cli.options import OrderedGroup +from code42cli.options import sdk_options +from code42cli.util import find_format_width +from code42cli.util import format_to_table -class AlertRuleTypes(object): +class AlertRuleTypes: EXFILTRATION = "FED_ENDPOINT_EXFILTRATION" CLOUD_SHARE = "FED_CLOUD_SHARE_PERMISSIONS" FILE_TYPE_MISMATCH = "FED_FILE_TYPE_MISMATCH" @@ -37,14 +40,19 @@ def alert_rules(state): pass -rule_id_option = click.option("--rule-id", required=True, help="Observer ID of the rule.") +rule_id_option = click.option( + "--rule-id", required=True, help="Observer ID of the rule." +) username_option = click.option("-u", "--username", required=True) @alert_rules.command() @rule_id_option @click.option( - "-u", "--username", required=True, help="The username of the user to add to the alert rule.", + "-u", + "--username", + required=True, + help="The username of the user to add to the alert rule.", ) @sdk_options def add_user(state, rule_id, username): @@ -113,9 +121,13 @@ def bulk(state): @sdk_options def add(state, csv_rows): sdk = state.sdk + def handle_row(rule_id, username): _add_user(sdk, rule_id, username) - run_bulk_process(handle_row, csv_rows, progress_label="Adding users to alert-rules:") + + run_bulk_process( + handle_row, csv_rows, progress_label="Adding users to alert-rules:" + ) @bulk.command( @@ -127,9 +139,13 @@ def handle_row(rule_id, username): @sdk_options def remove(state, csv_rows): sdk = state.sdk + def handle_row(rule_id, username): _remove_user(sdk, rule_id, username) - run_bulk_process(handle_row, csv_rows, progress_label="Removing users from alert-rules:") + + run_bulk_process( + handle_row, csv_rows, progress_label="Removing users from alert-rules:" + ) def _add_user(sdk, rule_id, username): @@ -156,7 +172,9 @@ def _remove_user(sdk, rule_id, username): def _get_all_rules_metadata(sdk): rules_generator = sdk.alerts.rules.get_all() - selected_rules = [rule for rules in rules_generator for rule in rules["ruleMetadata"]] + selected_rules = [ + rule for rules in rules_generator for rule in rules["ruleMetadata"] + ] return _handle_rules_results(selected_rules) @@ -167,7 +185,7 @@ def _get_rule_metadata(sdk, rule_id): def _handle_rules_results(rules, rule_id=None): id_msg = "with RuleId {} ".format(rule_id) if rule_id else "" - msg = "No alert rules {0}found.".format(id_msg) + msg = "No alert rules {}found.".format(id_msg) if not rules: echo(msg) return rules diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index ec79f8f94..0a1a00259 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -1,134 +1,119 @@ import click +import py42.sdk.queries.alerts.filters as f from c42eventextractor.extractors import AlertExtractor from click import echo -from py42.sdk.queries.alerts.filters import * +import code42cli.cmds.search.enums as enum +import code42cli.cmds.search.extraction as ext +import code42cli.cmds.search.options as searchopt import code42cli.errors as errors +import code42cli.options as opt from code42cli.cmds.search import logger_factory from code42cli.cmds.search.cursor_store import AlertCursorStore -from code42cli.cmds.search.enums import ( - AlertOutputFormat, - AlertSeverity as AlertSeverityOptions, - AlertState as AlertStateOptions, - RuleType as RuleTypeOptions, -) -from code42cli.cmds.search.extraction import ( - create_handlers, - create_time_range_filter, -) -from code42cli.cmds.search.options import ( - create_search_options, - AdvancedQueryAndSavedSearchIncompatible, - is_in_filter, - contains_filter, - not_contains_filter, - not_in_filter, -) -from code42cli.options import sdk_options, OrderedGroup -search_options = create_search_options("alerts") +search_options = searchopt.create_search_options("alerts") format_option = click.option( "-f", "--format", - type=click.Choice(AlertOutputFormat()), - default=AlertOutputFormat.JSON, + type=click.Choice(enum.AlertOutputFormat()), + default=enum.AlertOutputFormat.JSON, help="The format used for outputting alerts.", ) severity_option = click.option( "--severity", multiple=True, - type=click.Choice(AlertSeverityOptions()), - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=is_in_filter(Severity), + type=click.Choice(enum.AlertSeverity()), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.Severity), help="Filter alerts by severity. Defaults to returning all severities.", ) state_option = click.option( "--state", multiple=True, - type=click.Choice(AlertStateOptions()), - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=is_in_filter(AlertState), + type=click.Choice(enum.AlertState()), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.AlertState), help="Filter alerts by state. Defaults to returning all states.", ) actor_option = click.option( "--actor", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=is_in_filter(Actor), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.Actor), help="Filter alerts by including the given actor(s) who triggered the alert. " "Args must match actor username exactly.", ) actor_contains_option = click.option( "--actor-contains", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=contains_filter(Actor), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.contains_filter(f.Actor), help="Filter alerts by including actor(s) whose username contains the given string.", ) exclude_actor_option = click.option( "--exclude-actor", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=not_in_filter(Actor), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.not_in_filter(f.Actor), help="Filter alerts by excluding the given actor(s) who triggered the alert. " "Args must match actor username exactly.", ) exclude_actor_contains_option = click.option( "--exclude-actor-contains", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=not_contains_filter(Actor), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.not_contains_filter(f.Actor), help="Filter alerts by excluding actor(s) whose username contains the given string.", ) rule_name_option = click.option( "--rule-name", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=is_in_filter(RuleName), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.RuleName), help="Filter alerts by including the given rule name(s).", ) exclude_rule_name_option = click.option( "--exclude-rule-name", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=not_in_filter(RuleName), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.not_in_filter(f.RuleName), help="Filter alerts by excluding the given rule name(s).", ) rule_id_option = click.option( "--rule-id", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=is_in_filter(RuleId), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.RuleId), help="Filter alerts by including the given rule id(s).", ) exclude_rule_id_option = click.option( "--exclude-rule-id", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=not_in_filter(RuleId), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.not_in_filter(f.RuleId), help="Filter alerts by excluding the given rule id(s).", ) rule_type_option = click.option( "--rule-type", multiple=True, - type=click.Choice(RuleTypeOptions()), - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=is_in_filter(RuleType), + type=click.Choice(enum.RuleType()), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.RuleType), help="Filter alerts by including the given rule type(s).", ) exclude_rule_type_option = click.option( "--exclude-rule-type", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=not_in_filter(RuleType), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.not_in_filter(f.RuleType), help="Filter alerts by excluding the given rule type(s).", ) description_option = click.option( "--description", multiple=True, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=contains_filter(Description), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.contains_filter(f.Description), help="Filter alerts by description. Does fuzzy search by default.", ) @@ -151,8 +136,8 @@ def alert_options(f): return f -@click.group(cls=OrderedGroup) -@sdk_options +@click.group(cls=opt.OrderedGroup) +@opt.sdk_options def alerts(state): """Tools for getting alert data.""" # store cursor getter on the group state so shared --begin option can use it in validation @@ -161,7 +146,7 @@ def alerts(state): @alerts.command() @click.argument("checkpoint-name") -@sdk_options +@opt.sdk_options def clear_checkpoint(state, checkpoint_name): """Remove the saved alert checkpoint from '--use-checkpoint/-c' mode.""" _get_alert_cursor_store(state.profile.name).delete(checkpoint_name) @@ -170,18 +155,22 @@ def clear_checkpoint(state, checkpoint_name): @alerts.command() @alert_options @search_options -@sdk_options +@opt.sdk_options def search(cli_state, format, begin, end, advanced_query, use_checkpoint, **kwargs): """Search for alerts.""" output_logger = logger_factory.get_logger_for_stdout(format) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None - handlers = create_handlers(cli_state.sdk, AlertExtractor, output_logger, cursor, use_checkpoint) + handlers = ext.create_handlers( + cli_state.sdk, AlertExtractor, output_logger, cursor, use_checkpoint + ) extractor = _get_alert_extractor(cli_state.sdk, handlers) if advanced_query: extractor.extract_advanced(advanced_query) else: if begin or end: - cli_state.search_filters.append(create_time_range_filter(DateObserved, begin, end)) + cli_state.search_filters.append( + ext.create_time_range_filter(f.DateObserved, begin, end) + ) extractor.extract(*cli_state.search_filters) if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: echo("No results found.") diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 9195b6eea..a6f2faa1e 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -1,16 +1,18 @@ import click from py42.exceptions import Py42BadRequestError -from code42cli.bulk import generate_template_cmd_factory, run_bulk_process -from code42cli.cmds.detectionlists import update_user, try_handle_user_already_added_error -from code42cli.cmds.detectionlists.options import ( - username_arg, - cloud_alias_option, - notes_option, -) +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process +from code42cli.cmds.detectionlists import try_handle_user_already_added_error +from code42cli.cmds.detectionlists import update_user +from code42cli.cmds.detectionlists.options import cloud_alias_option +from code42cli.cmds.detectionlists.options import notes_option +from code42cli.cmds.detectionlists.options import username_arg from code42cli.cmds.shared import get_user_id -from code42cli.file_readers import read_csv_arg, read_flat_file_arg -from code42cli.options import sdk_options, OrderedGroup +from code42cli.file_readers import read_csv_arg +from code42cli.file_readers import read_flat_file_arg +from code42cli.options import OrderedGroup +from code42cli.options import sdk_options @click.group(cls=OrderedGroup) @@ -22,7 +24,9 @@ def departing_employee(state): @departing_employee.command() @username_arg -@click.option("--departure-date", help="The date the employee is departing. Format: yyyy-MM-dd.") +@click.option( + "--departure-date", help="The date the employee is departing. Format: yyyy-MM-dd." +) @cloud_alias_option @notes_option @sdk_options @@ -56,32 +60,38 @@ def bulk(state): @bulk.command( + name="add", help="Bulk add users to the departing-employee detection list using a csv file with " - "format: {}".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)) + "format: {}".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @sdk_options -def add(state, csv_rows): +def bulk_add(state, csv_rows): sdk = state.sdk + def handle_row(username, cloud_alias, departure_date, notes): - _add_departing_employee( - sdk, username, cloud_alias, departure_date, notes - ) + _add_departing_employee(sdk, username, cloud_alias, departure_date, notes) + run_bulk_process( - handle_row, csv_rows, progress_label="Adding users to departing employee detection list:" + handle_row, + csv_rows, + progress_label="Adding users to departing employee detection list:", ) @bulk.command( + name="remove", help="Bulk remove users from the departing-employee detection list using a newline separated " - "file of usernames." + "file of usernames.", ) @read_flat_file_arg @sdk_options -def remove(state, file_rows): +def bulk_remove(state, file_rows): sdk = state.sdk + def handle_row(username): _remove_departing_employee(sdk, username) + run_bulk_process( handle_row, file_rows, diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 8ca349d7e..1d66a2012 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -33,7 +33,9 @@ def remove_risk_tags(sdk, username, risk_tag): sdk.detectionlists.remove_user_risk_tags(user_id, risk_tag) -def try_handle_user_already_added_error(bad_request_err, username_tried_adding, list_name): +def try_handle_user_already_added_error( + bad_request_err, username_tried_adding, list_name +): if _error_is_user_already_added(bad_request_err.response.text): raise UserAlreadyAddedError(username_tried_adding, list_name) return False @@ -44,7 +46,7 @@ def _error_is_user_already_added(bad_request_error_text): def handle_list_args(list_arg): - """Converts str args to a list. Useful for `bulk` commands which don't use click's argument + """Converts str args to a list. Useful for `bulk` commands which don't use click's argument parsing but instead pass in values from files, such as in the form "item1 item2".""" if isinstance(list_arg, str): return list_arg.split() diff --git a/src/code42cli/cmds/detectionlists/enums.py b/src/code42cli/cmds/detectionlists/enums.py index 45034c39d..0a22d92b7 100644 --- a/src/code42cli/cmds/detectionlists/enums.py +++ b/src/code42cli/cmds/detectionlists/enums.py @@ -1,4 +1,4 @@ -class RiskTags(object): +class RiskTags: FLIGHT_RISK = "FLIGHT_RISK" HIGH_IMPACT_EMPLOYEE = "HIGH_IMPACT_EMPLOYEE" ELEVATED_ACCESS_PRIVILEGES = "ELEVATED_ACCESS_PRIVILEGES" diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index e203d5e60..fa6c51fb0 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -1,23 +1,22 @@ import click from py42.exceptions import Py42BadRequestError -from code42cli.bulk import run_bulk_process, generate_template_cmd_factory -from code42cli.cmds.detectionlists import ( - update_user, - add_risk_tags as _add_risk_tags, - remove_risk_tags as _remove_risk_tags, - try_handle_user_already_added_error, - handle_list_args, -) +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process +from code42cli.cmds.detectionlists import add_risk_tags as _add_risk_tags +from code42cli.cmds.detectionlists import handle_list_args +from code42cli.cmds.detectionlists import remove_risk_tags as _remove_risk_tags +from code42cli.cmds.detectionlists import try_handle_user_already_added_error +from code42cli.cmds.detectionlists import update_user from code42cli.cmds.detectionlists.enums import RiskTags -from code42cli.cmds.detectionlists.options import ( - cloud_alias_option, - notes_option, - username_arg, -) +from code42cli.cmds.detectionlists.options import cloud_alias_option +from code42cli.cmds.detectionlists.options import notes_option +from code42cli.cmds.detectionlists.options import username_arg from code42cli.cmds.shared import get_user_id -from code42cli.file_readers import read_csv_arg, read_flat_file_arg -from code42cli.options import sdk_options, OrderedGroup +from code42cli.file_readers import read_csv_arg +from code42cli.file_readers import read_flat_file_arg +from code42cli.options import OrderedGroup +from code42cli.options import sdk_options risk_tag_option = click.option( "-t", @@ -95,32 +94,38 @@ def bulk(state): @bulk.command( + name="add", help="Bulk add users to the high-risk-employee detection list using a csv file with " - "format: {}".format(",".join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)) + "format: {}".format(",".join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) @sdk_options -def add(state, csv_rows): +def bulk_add(state, csv_rows): sdk = state.sdk + def handle_row(username, cloud_alias, risk_tag, notes): - _add_high_risk_employee( - sdk, username, cloud_alias, risk_tag, notes - ) + _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes) + run_bulk_process( - handle_row, csv_rows, progress_label="Adding users to high risk employee detection list:" + handle_row, + csv_rows, + progress_label="Adding users to high risk employee detection list:", ) @bulk.command( + name="remove", help="Bulk remove users from the high-risk-employee detection list using a newline separated " - "file of usernames." + "file of usernames.", ) @read_flat_file_arg @sdk_options -def remove(state, file_rows): +def bulk_remove(state, file_rows): sdk = state.sdk + def handle_row(username): _remove_high_risk_employee(sdk, username) + run_bulk_process( handle_row, file_rows, @@ -129,32 +134,38 @@ def handle_row(username): @bulk.command( + name="add-risk-tags", help="Adds risk tags to users in bulk using a csv file with format: {}".format( ",".join(RISK_TAG_CSV_HEADERS) - ) + ), ) @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) @sdk_options -def add_risk_tags(state, csv_rows): +def bulk_add_risk_tags(state, csv_rows): sdk = state.sdk + def handle_row(username, tag): _add_risk_tags(sdk, username, tag) + run_bulk_process( handle_row, csv_rows, progress_label="Adding risk tags to users:", ) @bulk.command( + name="remove-risk-tags", help="Removes risk tags from users in bulk using a csv file with format: {}".format( ",".join(RISK_TAG_CSV_HEADERS) - ) + ), ) @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) @sdk_options -def remove_risk_tags(state, csv_rows): +def bulk_remove_risk_tags(state, csv_rows): sdk = state.sdk + def handle_row(username, tag): _remove_risk_tags(sdk, username, tag) + run_bulk_process( handle_row, csv_rows, progress_label="Removing risk tags from users:", ) @@ -166,7 +177,9 @@ def _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes): try: sdk.detectionlists.high_risk_employee.add(user_id) - update_user(sdk, username, cloud_alias=cloud_alias, risk_tag=risk_tag, notes=notes) + update_user( + sdk, username, cloud_alias=cloud_alias, risk_tag=risk_tag, notes=notes + ) except Py42BadRequestError as err: try_handle_user_already_added_error(err, username, "high-risk-employee list") raise diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 8225a7ce1..780f0d25f 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -1,26 +1,25 @@ +import json from collections import OrderedDict from functools import lru_cache -import json from pprint import pformat import click from click import echo -from py42.exceptions import Py42ForbiddenError, Py42BadRequestError +from py42.exceptions import Py42BadRequestError +from py42.exceptions import Py42ForbiddenError -from code42cli.bulk import run_bulk_process, generate_template_cmd_factory +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process from code42cli.cmds.shared import get_user_id -from code42cli.errors import ( - UserAlreadyAddedError, - UserNotInLegalHoldError, - LegalHoldNotFoundOrPermissionDeniedError, -) +from code42cli.errors import LegalHoldNotFoundOrPermissionDeniedError +from code42cli.errors import UserAlreadyAddedError +from code42cli.errors import UserNotInLegalHoldError from code42cli.file_readers import read_csv_arg -from code42cli.options import sdk_options, OrderedGroup -from code42cli.util import ( - format_to_table, - find_format_width, - format_string_list_to_columns, -) +from code42cli.options import OrderedGroup +from code42cli.options import sdk_options +from code42cli.util import find_format_width +from code42cli.util import format_string_list_to_columns +from code42cli.util import format_to_table _MATTER_KEYS_MAP = OrderedDict() _MATTER_KEYS_MAP["legalHoldUid"] = "Matter ID" @@ -94,8 +93,12 @@ def show(state, matter_id, include_inactive=False, include_policy=False): # if `active` is None then all matters (whether active or inactive) are returned. True returns # only those that are active. active = None if include_inactive else True - memberships = _get_legal_hold_memberships_for_matter(state.sdk, matter_id, active=active) - active_usernames = [member["user"]["username"] for member in memberships if member["active"]] + memberships = _get_legal_hold_memberships_for_matter( + state.sdk, matter_id, active=active + ) + active_usernames = [ + member["user"]["username"] for member in memberships if member["active"] + ] inactive_usernames = [ member["user"]["username"] for member in memberships if not member["active"] ] @@ -132,16 +135,19 @@ def bulk(state): @bulk.command( + name="add", help="Bulk add users to legal hold matters from a csv file. CSV file format: {}".format( ",".join(LEGAL_HOLD_CSV_HEADERS) - ) + ), ) @read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) @sdk_options -def add(state, csv_rows): +def bulk_add(state, csv_rows): sdk = state.sdk + def handle_row(matter_id, username): _add_user_to_legal_hold(sdk, matter_id, username) + run_bulk_process(handle_row, csv_rows, progress_label="Adding users to legal hold:") @@ -154,9 +160,13 @@ def handle_row(matter_id, username): @sdk_options def remove(state, csv_rows): sdk = state.sdk + def handle_row(matter_id, username): _remove_user_from_legal_hold(sdk, matter_id, username) - run_bulk_process(handle_row, csv_rows, progress_label="Removing users from legal hold:") + + run_bulk_process( + handle_row, csv_rows, progress_label="Removing users from legal hold:" + ) def _add_user_to_legal_hold(sdk, matter_id, username): @@ -175,7 +185,9 @@ def _add_user_to_legal_hold(sdk, matter_id, username): def _remove_user_from_legal_hold(sdk, matter_id, username): _check_matter_is_accessible(sdk, matter_id) - membership_id = _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id) + membership_id = _get_legal_hold_membership_id_for_user_and_matter( + sdk, username, matter_id + ) sdk.legalhold.remove_from_matter(membership_id) @@ -199,7 +211,9 @@ def _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True): legal_hold_uid=matter_id, active=active ) memberships = [ - member for page in memberships_generator for member in page["legalHoldMemberships"] + member + for page in memberships_generator + for member in page["legalHoldMemberships"] ] return memberships @@ -207,7 +221,10 @@ def _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True): def _get_all_active_matters(sdk): matters_generator = sdk.legalhold.get_all_matters() matters = [ - matter for page in matters_generator for matter in page["legalHolds"] if matter["active"] + matter + for page in matters_generator + for matter in page["legalHolds"] + if matter["active"] ] for matter in matters: matter["creator_username"] = matter["creator"]["username"] diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 09dcd6e08..1247f2b6f 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -1,7 +1,8 @@ from getpass import getpass import click -from click import echo, secho +from click import echo +from click import secho import code42cli.profile as cliprofile from code42cli.errors import Code42CLIError @@ -25,16 +26,27 @@ def profile(): help="The name of the Code42 CLI profile to use when executing this command.", ) server_option = click.option( - "-s", "--server", required=True, help="The url and port of the Code42 server." + "-s", + "--server", + required=True, + type=str, + help="The url and port of the Code42 server.", ) + username_option = click.option( - "-u", "--username", required=True, help="The username of the Code42 API user." + "-u", + "--username", + required=True, + type=str, + help="The username of the Code42 API user.", ) + password_option = click.option( "--password", help="The password for the Code42 API user. If this option is omitted, interactive prompts " "will be used to obtain the password.", ) + disable_ssl_option = click.option( "--disable-ssl-errors", is_flag=True, @@ -48,7 +60,7 @@ def profile(): def show(profile_name): """Print the details of a profile.""" c42profile = cliprofile.get_profile(profile_name) - echo("\n{0}:".format(c42profile.name)) + echo("\n{}:".format(c42profile.name)) echo("\t* username = {}".format(c42profile.username)) echo("\t* authority url = {}".format(c42profile.authority_url)) echo("\t* ignore-ssl-errors = {}".format(c42profile.ignore_ssl_errors)) @@ -95,7 +107,7 @@ def update(name, server, username, password, disable_ssl_errors): @profile_name_arg def reset_pw(profile_name): """\b - Change the stored password for a profile. Only affects what's stored in the local profile, + Change the stored password for a profile. Only affects what's stored in the local profile, does not make any changes to the Code42 user account.""" password = getpass() _set_pw(profile_name, password) @@ -127,7 +139,9 @@ def delete(profile_name): """Deletes a profile and its stored password (if any).""" message = "\nDeleting this profile will also delete any stored passwords and checkpoints. Are you sure? (y/n): " if cliprofile.is_default_profile(profile_name): - message = "\n'{0}' is currently the default profile!\n{1}".format(profile_name, message) + message = "\n'{}' is currently the default profile!\n{}".format( + profile_name, message + ) if does_user_agree(message): cliprofile.delete_profile(profile_name) echo("Profile '{}' has been deleted.".format(profile_name)) diff --git a/src/code42cli/cmds/search/cursor_store.py b/src/code42cli/cmds/search/cursor_store.py index 63e55cb89..2dd74586d 100644 --- a/src/code42cli/cmds/search/cursor_store.py +++ b/src/code42cli/cmds/search/cursor_store.py @@ -5,7 +5,7 @@ from code42cli.util import get_user_project_path -class Cursor(object): +class Cursor: def __init__(self, location): self._location = location self._name = path.basename(location) @@ -20,7 +20,7 @@ def value(self): return checkpoint.read() -class BaseCursorStore(object): +class BaseCursorStore: def __init__(self, dir_path): self._dir_path = dir_path @@ -45,7 +45,7 @@ def delete(self, cursor_name): location = path.join(self._dir_path, cursor_name) os.remove(location) except FileNotFoundError: - msg = "No checkpoint named {0} exists for this profile.".format(cursor_name) + msg = "No checkpoint named {} exists for this profile.".format(cursor_name) raise Code42CLIError(msg) def clean(self): @@ -66,13 +66,13 @@ def _is_file(self, node_name): class FileEventCursorStore(BaseCursorStore): def __init__(self, profile_name): dir_path = get_user_project_path("file_event_checkpoints", profile_name) - super(FileEventCursorStore, self).__init__(dir_path) + super().__init__(dir_path) class AlertCursorStore(BaseCursorStore): def __init__(self, profile_name): dir_path = get_user_project_path("alert_checkpoints", profile_name) - super(AlertCursorStore, self).__init__(dir_path) + super().__init__(dir_path) def get_file_event_cursor_store(profile_name): diff --git a/src/code42cli/cmds/search/enums.py b/src/code42cli/cmds/search/enums.py index 490b0dfed..e0f9c3e8f 100644 --- a/src/code42cli/cmds/search/enums.py +++ b/src/code42cli/cmds/search/enums.py @@ -1,7 +1,7 @@ IS_CHECKPOINT_KEY = "use_checkpoint" -class OutputFormat(object): +class OutputFormat: CEF = "CEF" JSON = "JSON" RAW = "RAW-JSON" @@ -10,7 +10,7 @@ def __iter__(self): return iter([self.CEF, self.JSON, self.RAW]) -class AlertOutputFormat(object): +class AlertOutputFormat: JSON = "JSON" RAW = "RAW-JSON" @@ -18,7 +18,7 @@ def __iter__(self): return iter([self.JSON, self.RAW]) -class AlertSeverity(object): +class AlertSeverity: HIGH = "HIGH" MEDIUM = "MEDIUM" LOW = "LOW" @@ -33,7 +33,7 @@ def _as_list(self): return [self.HIGH, self.MEDIUM, self.LOW] -class AlertState(object): +class AlertState: OPEN = "OPEN" DISMISSED = "RESOLVED" @@ -47,7 +47,7 @@ def _as_list(self): return [self.OPEN, self.DISMISSED] -class ExposureType(object): +class ExposureType: SHARED_VIA_LINK = "SharedViaLink" SHARED_TO_DOMAIN = "SharedToDomain" APPLICATION_READ = "ApplicationRead" @@ -72,7 +72,7 @@ def _as_list(self): ] -class RuleType(object): +class RuleType: ENDPOINT_EXFILTRATION = "FedEndpointExfiltration" CLOUD_SHARE_PERMISSIONS = "FedCloudSharePermissions" FILE_TYPE_MISMATCH = "FedFileTypeMismatch" @@ -84,10 +84,14 @@ def __len__(self): return len(self._as_list()) def _as_list(self): - return [self.ENDPOINT_EXFILTRATION, self.CLOUD_SHARE_PERMISSIONS, self.FILE_TYPE_MISMATCH] + return [ + self.ENDPOINT_EXFILTRATION, + self.CLOUD_SHARE_PERMISSIONS, + self.FILE_TYPE_MISMATCH, + ] -class ServerProtocol(object): +class ServerProtocol: TCP = "TCP" UDP = "UDP" diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index 878a5984f..a1d22f40b 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -36,7 +36,7 @@ def create_handlers(sdk, extractor_class, output_logger, cursor_store, checkpoin def handle_error(exception): errors.ERRORED = True if hasattr(exception, "response") and hasattr(exception.response, "text"): - message = "{0}: {1}".format(exception, exception.response.text) + message = "{}: {}".format(exception, exception.response.text) else: message = exception logger.log_error(message) @@ -45,7 +45,9 @@ def handle_error(exception): handlers.handle_error = handle_error if cursor_store: - handlers.record_cursor_position = lambda value: cursor_store.replace(checkpoint_name, value) + handlers.record_cursor_position = lambda value: cursor_store.replace( + checkpoint_name, value + ) handlers.get_cursor_position = lambda: cursor_store.get(checkpoint_name) @warn_interrupt( @@ -72,12 +74,12 @@ def handle_response(response): def create_time_range_filter(filter_cls, begin_date=None, end_date=None): - """Creates a filter using the given filter class (must be a subclass of - :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) and date args. Returns + """Creates a filter using the given filter class (must be a subclass of + :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) and date args. Returns `None` if both begin_date and end_date args are `None`. - + Args: - filter_cls: The class of filter to create. (must be a subclass of + filter_cls: The class of filter to create. (must be a subclass of :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) begin_date: The begin date for the range. end_date: The end date for the range. diff --git a/src/code42cli/cmds/search/logger_factory.py b/src/code42cli/cmds/search/logger_factory.py index b5855eb1b..5ca007fb9 100644 --- a/src/code42cli/cmds/search/logger_factory.py +++ b/src/code42cli/cmds/search/logger_factory.py @@ -1,16 +1,12 @@ import logging -from c42eventextractor.logging.formatters import ( - FileEventDictToCEFFormatter, - FileEventDictToJSONFormatter, - FileEventDictToRawJSONFormatter, -) +from c42eventextractor.logging.formatters import FileEventDictToCEFFormatter +from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter +from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter from code42cli.cmds.search.enums import OutputFormat -from code42cli.logger import ( - add_handler_to_logger, - get_logger_for_stdout as get_stdout_logger, -) +from code42cli.logger import add_handler_to_logger +from code42cli.logger import get_logger_for_stdout as get_stdout_logger def get_logger_for_stdout(output_format): diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index 41539ad58..d22c73051 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -1,9 +1,11 @@ import json -from datetime import datetime, timezone +from datetime import datetime +from datetime import timezone import click -from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp +from code42cli.date_helper import parse_max_timestamp +from code42cli.date_helper import parse_min_timestamp from code42cli.logger import get_main_cli_logger from code42cli.options import incompatible_with @@ -64,10 +66,14 @@ def validate_advanced_query_is_json(ctx, param, arg): json.loads(arg) return arg except json.JSONDecodeError: - raise click.ClickException("Failed to parse advanced query, must be a valid json string.") + raise click.ClickException( + "Failed to parse advanced query, must be a valid json string." + ) -AdvancedQueryAndSavedSearchIncompatible = incompatible_with(["advanced_query", "saved_search"]) +AdvancedQueryAndSavedSearchIncompatible = incompatible_with( + ["advanced_query", "saved_search"] +) class BeginOption(AdvancedQueryAndSavedSearchIncompatible): @@ -78,15 +84,25 @@ def __init__(self, *args, **kwargs): def handle_parse_result(self, ctx, opts, args): # if ctx.obj is None it means we're in autocomplete mode and don't want to validate - if ctx.obj is not None and "saved_search" not in opts and "advanced_query" not in opts: + if ( + ctx.obj is not None + and "saved_search" not in opts + and "advanced_query" not in opts + ): profile = opts.get("profile") or ctx.obj.profile.name cursor = ctx.obj.cursor_getter(profile) checkpoint_arg_present = "use_checkpoint" in opts checkpoint_value = ( - cursor.get(opts.get("use_checkpoint", "")) if checkpoint_arg_present else None + cursor.get(opts.get("use_checkpoint", "")) + if checkpoint_arg_present + else None ) begin_present = "begin" in opts - if checkpoint_arg_present and checkpoint_value is not None and begin_present: + if ( + checkpoint_arg_present + and checkpoint_value is not None + and begin_present + ): opts.pop("begin") checkpoint_value_str = datetime.fromtimestamp( checkpoint_value, timezone.utc @@ -97,7 +113,11 @@ def handle_parse_result(self, ctx, opts, args): ), err=True, ) - if checkpoint_arg_present and checkpoint_value is None and not begin_present: + if ( + checkpoint_arg_present + and checkpoint_value is None + and not begin_present + ): raise click.UsageError( message="--begin date is required for --use-checkpoint when no checkpoint " "exists yet.", @@ -140,7 +160,7 @@ def create_search_options(search_term): "-c", "--use-checkpoint", cls=AdvancedQueryAndSavedSearchIncompatible, - help="Only get {0} that were not previously retrieved.".format(search_term), + help="Only get {} that were not previously retrieved.".format(search_term), ) def search_options(f): diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 0f59f2f1d..2a03e8d18 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -1,120 +1,112 @@ from pprint import pformat import click +import py42.sdk.queries.fileevents.filters as f from c42eventextractor.extractors import FileEventExtractor from click import echo -from py42.sdk.queries.fileevents.filters import * +import code42cli.cmds.search.enums as enum +import code42cli.cmds.search.extraction as ext +import code42cli.cmds.search.options as searchopt import code42cli.errors as errors from code42cli.cmds.search import logger_factory from code42cli.cmds.search.cursor_store import FileEventCursorStore -from code42cli.cmds.search.enums import ( - OutputFormat, - ExposureType as ExposureTypeOptions, -) -from code42cli.cmds.search.extraction import ( - create_handlers, - create_time_range_filter, -) -from code42cli.cmds.search.options import ( - create_search_options, - AdvancedQueryAndSavedSearchIncompatible, - is_in_filter, - exists_filter, -) from code42cli.logger import get_main_cli_logger -from code42cli.options import sdk_options, incompatible_with, OrderedGroup -from code42cli.util import format_to_table, find_format_width +from code42cli.options import incompatible_with +from code42cli.options import OrderedGroup +from code42cli.options import sdk_options +from code42cli.util import find_format_width +from code42cli.util import format_to_table logger = get_main_cli_logger() -search_options = create_search_options("file events") +search_options = searchopt.create_search_options("file events") format_option = click.option( "-f", "--format", - type=click.Choice(OutputFormat()), - default=OutputFormat.JSON, + type=click.Choice(enum.OutputFormat()), + default=enum.OutputFormat.JSON, help="The format used for outputting file events.", ) exposure_type_option = click.option( "-t", "--type", multiple=True, - type=click.Choice(list(ExposureTypeOptions())), - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=is_in_filter(ExposureType), + type=click.Choice(list(enum.ExposureType())), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.ExposureType), help="Limits events to those with given exposure types.", ) username_option = click.option( "--c42-username", multiple=True, - callback=is_in_filter(DeviceUsername), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.DeviceUsername), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to endpoint events for these users.", ) actor_option = click.option( "--actor", multiple=True, - callback=is_in_filter(Actor), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.Actor), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to only those enacted by the cloud service user " "of the person who caused the event.", ) md5_option = click.option( "--md5", multiple=True, - callback=is_in_filter(MD5), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.MD5), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file has one of these MD5 hashes.", ) sha256_option = click.option( "--sha256", multiple=True, - callback=is_in_filter(SHA256), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.SHA256), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file has one of these SHA256 hashes.", ) source_option = click.option( "--source", multiple=True, - callback=is_in_filter(Source), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.Source), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to only those from one of these sources. Example=Gmail.", ) file_name_option = click.option( "--file-name", multiple=True, - callback=is_in_filter(FileName), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.FileName), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file has one of these names.", ) file_path_option = click.option( "--file-path", multiple=True, - callback=is_in_filter(FilePath), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.FilePath), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file is located at one of these paths.", ) process_owner_option = click.option( "--process-owner", multiple=True, - callback=is_in_filter(ProcessOwner), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.ProcessOwner), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to exposure events where one of these users owns " "the process behind the exposure.", ) tab_url_option = click.option( "--tab-url", multiple=True, - callback=is_in_filter(TabURL), - cls=AdvancedQueryAndSavedSearchIncompatible, + callback=searchopt.is_in_filter(f.TabURL), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to be exposure events with one of these destination tab URLs.", ) include_non_exposure_option = click.option( "--include-non-exposure", is_flag=True, - callback=exists_filter(ExposureType), + callback=searchopt.exists_filter(f.ExposureType), cls=incompatible_with(["advanced_query", "type", "saved_search"]), help="Get all events including non-exposure events.", ) @@ -172,11 +164,17 @@ def clear_checkpoint(state, checkpoint_name): @file_event_options @search_options @sdk_options -def search(state, format, begin, end, advanced_query, use_checkpoint, saved_search, **kwargs): +def search( + state, format, begin, end, advanced_query, use_checkpoint, saved_search, **kwargs +): """Search for file events.""" output_logger = logger_factory.get_logger_for_stdout(format) - cursor = _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None - handlers = create_handlers(state.sdk, FileEventExtractor, output_logger, cursor, use_checkpoint) + cursor = ( + _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None + ) + handlers = ext.create_handlers( + state.sdk, FileEventExtractor, output_logger, cursor, use_checkpoint + ) extractor = _get_file_event_extractor(state.sdk, handlers) if advanced_query: extractor.extract_advanced(advanced_query) @@ -184,7 +182,9 @@ def search(state, format, begin, end, advanced_query, use_checkpoint, saved_sear extractor.extract(*saved_search._filter_group_list) else: if begin or end: - state.search_filters.append(create_time_range_filter(EventTimestamp, begin, end)) + state.search_filters.append( + ext.create_time_range_filter(f.EventTimestamp, begin, end) + ) extractor.extract(*state.search_filters) if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: echo("No results found.") diff --git a/src/code42cli/cmds/shared.py b/src/code42cli/cmds/shared.py index 503d5075e..c8c1a3688 100644 --- a/src/code42cli/cmds/shared.py +++ b/src/code42cli/cmds/shared.py @@ -5,13 +5,13 @@ @lru_cache(maxsize=None) def get_user_id(sdk, username): - """Returns the user's UID (referred to by `user_id` in detection lists). Raises + """Returns the user's UID (referred to by `user_id` in detection lists). Raises `UserDoesNotExistError` if the user doesn't exist in the Code42 server. - + Args: sdk (py42.sdk.SDKClient): The py42 sdk. username (str or unicode): The username of the user to get an ID for. - + Returns: str: The user ID for the user with the given username. """ diff --git a/src/code42cli/config.py b/src/code42cli/config.py index edd9ccd9f..fddd8fbdc 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -1,8 +1,6 @@ import os from configparser import ConfigParser -from click import echo - import code42cli.util as util @@ -13,10 +11,10 @@ def __init__(self, profile_arg_name=None): if profile_arg_name else "Profile does not exist." ) - super(NoConfigProfileError, self).__init__(message) + super().__init__(message) -class ConfigAccessor(object): +class ConfigAccessor: DEFAULT_VALUE = "__DEFAULT__" AUTHORITY_KEY = "c42_authority_url" USERNAME_KEY = "c42_username" diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py index 44841ee8a..138f6b15b 100644 --- a/src/code42cli/date_helper.py +++ b/src/code42cli/date_helper.py @@ -1,5 +1,6 @@ import re -from datetime import datetime, timedelta +from datetime import datetime +from datetime import timedelta import click from c42eventextractor.common import convert_datetime_to_timestamp @@ -27,9 +28,13 @@ def parse_min_timestamp(begin_date_str, max_days_back=90): if begin_date_str is None: return dt = _parse_timestamp(begin_date_str, _round_datetime_to_day_start) - boundary_date = _round_datetime_to_day_start(datetime.utcnow() - timedelta(days=max_days_back)) + boundary_date = _round_datetime_to_day_start( + datetime.utcnow() - timedelta(days=max_days_back) + ) if dt < boundary_date: - raise click.BadParameter(message="must be within {0} days.".format(max_days_back)) + raise click.BadParameter( + message="must be within {} days.".format(max_days_back) + ) return convert_datetime_to_timestamp(dt) @@ -85,7 +90,9 @@ def _get_dt_from_magic_time_pair(num, period): elif period == "m": dt = datetime.utcnow() - timedelta(minutes=num) else: - raise click.ClickException("Couldn't parse magic time string: {}{}".format(num, period)) + raise click.ClickException( + "Couldn't parse magic time string: {}{}".format(num, period) + ) return dt diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index 472c6ddfc..824512573 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -3,9 +3,11 @@ import click from click._compat import get_text_stderr -from py42.exceptions import Py42ForbiddenError, Py42HTTPError +from py42.exceptions import Py42ForbiddenError +from py42.exceptions import Py42HTTPError -from code42cli.logger import get_view_error_details_message, get_main_cli_logger +from code42cli.logger import get_main_cli_logger +from code42cli.logger import get_view_error_details_message ERRORED = False _DIFFLIB_CUT_OFF = 0.6 @@ -14,7 +16,7 @@ class Code42CLIError(click.ClickException): """Base CLI exception. The `message` param automatically gets logged to error file and printed to stderr in red text. If `help` param is provided, it will also be printed to stderr after the - message but not logged to file. + message but not logged to file. """ def __init__(self, message, help=None): @@ -32,9 +34,9 @@ def show(self, file=None): class LoggedCLIError(Code42CLIError): """Exception to be raised when wanting to point users to error logs for error details. - - If `message` param is provided it will be printed to screen along with message on where to - find error details in the log. + + If `message` param is provided it will be printed to screen along with message on where to + find error details in the log. """ def __init__(self, message=None): @@ -44,7 +46,9 @@ def __init__(self, message=None): def format_message(self): locations_message = get_view_error_details_message() return ( - "{}\n{}".format(self.message, locations_message) if self.message else locations_message + "{}\n{}".format(self.message, locations_message) + if self.message + else locations_message ) @@ -62,8 +66,8 @@ def __init__(self, rule_id, source): class UserDoesNotExistError(Code42CLIError): - """An error to represent a username that is not in our system. The CLI shows this error when - the user tries to add or remove a user that does not exist. This error is not shown during + """An error to represent a username that is not in our system. The CLI shows this error when + the user tries to add or remove a user that does not exist. This error is not shown during bulk add or remove.""" def __init__(self, username): @@ -131,7 +135,7 @@ def invoke(self, ctx): self.logger.log_verbose_error(self._original_args, err.response.request) raise LoggedCLIError("Problem making request to server.") - except Exception as err: + except Exception: self.logger.log_verbose_error() raise LoggedCLIError("Unknown problem occurred.") diff --git a/src/code42cli/file_readers.py b/src/code42cli/file_readers.py index 81bf0719f..b4a48a58a 100644 --- a/src/code42cli/file_readers.py +++ b/src/code42cli/file_readers.py @@ -4,8 +4,8 @@ def read_csv_arg(headers): - """Helper for defining arguments that read from a csv file. Automatically converts - the file name provided on command line to a list of csv rows (passed to command + """Helper for defining arguments that read from a csv file. Automatically converts + the file name provided on command line to a list of csv rows (passed to command function as `csv_rows` param). """ return click.argument( @@ -24,7 +24,9 @@ def read_csv(file, headers=None): first_row = next(reader) if None in first_row or None in first_row.values(): raise click.BadParameter( - "Column count in {} doesn't match expected headers: {}".format(file.name, headers) + "Column count in {} doesn't match expected headers: {}".format( + file.name, headers + ) ) # skip first row if it's the header values if tuple(first_row.keys()) == tuple(first_row.values()): @@ -34,7 +36,7 @@ def read_csv(file, headers=None): def read_flat_file(file): - """Helper to read rows of a flat file, automatically removing header comment row if + """Helper to read rows of a flat file, automatically removing header comment row if it exists, and strips whitespace from each row automatically.""" first_row = next(file) if first_row.startswith("#"): diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index bb0af997d..8d4c8f4ef 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -15,8 +15,8 @@ def handleError(record): - """Override logger's `handleError` method to exit if an exception is raised while trying to - log, and replace stdout with devnull because if we're here it's usually because stdout has + """Override logger's `handleError` method to exit if an exception is raised while trying to + log, and replace stdout with devnull because if we're here it's usually because stdout has been closed on us. """ t, v, tb = sys.exc_info() @@ -51,7 +51,9 @@ def _get_error_log_path(): def _create_error_file_handler(): log_path = _get_error_log_path() - return RotatingFileHandler(log_path, maxBytes=250000000, encoding="utf-8", delay=True) + return RotatingFileHandler( + log_path, maxBytes=250000000, encoding="utf-8", delay=True + ) def add_handler_to_logger(logger, handler, formatter): @@ -88,7 +90,7 @@ def _create_formatter_for_error_file(): return logging.Formatter("%(asctime)s %(message)s") -class CliLogger(object): +class CliLogger: def __init__(self): self._logger = _get_error_file_logger() @@ -98,7 +100,7 @@ def log_error(self, err): self._logger.error(message) def log_verbose_error(self, invocation_str=None, http_request=None): - """For logging traces, invocation strs, and request parameters during exceptions to the + """For logging traces, invocation strs, and request parameters during exceptions to the error log file.""" prefix = ( "Exception occurred." diff --git a/src/code42cli/main.py b/src/code42cli/main.py index ced637b88..45e603e8f 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -18,9 +18,9 @@ from code42cli.options import sdk_options BANNER = """\b - dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. -dP `" dP Yb 8I Yb 88__ dP 88 "' dP' -Yb Yb dP 8I dY 88"" d888888 dP' + dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. +dP `" dP Yb 8I Yb 88__ dP 88 "' dP' +Yb Yb dP 8I dY 88"" d888888 dP' YboodP YbodP 8888Y" 888888 88 .d8888 code42cli version {}, by Code42 Software. diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 4efbb2e04..356f6e416 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -16,7 +16,7 @@ ) -class CLIState(object): +class CLIState: def __init__(self): try: self._profile = get_profile() @@ -48,14 +48,14 @@ def set_assume_yes(self, param): def set_profile(ctx, param, value): - """Sets the profile on the global state object when --profile is passed to commands + """Sets the profile on the global state object when --profile is passed to commands decorated with @global_options.""" if value: ctx.ensure_object(CLIState).profile = get_profile(value) def set_debug(ctx, param, value): - """Sets debug to True on global state object when --debug/-d is passed to commands decorated + """Sets debug to True on global state object when --debug/-d is passed to commands decorated with @global_options. """ if value: @@ -109,7 +109,9 @@ def handle_parse_result(self, ctx, opts, args): name = self.name.replace("_", "-") raise click.BadOptionUsage( option_name=self.name, - message="--{} can't be used with: {}".format(name, found_incompatible), + message="--{} can't be used with: {}".format( + name, found_incompatible + ), ) return super().handle_parse_result(ctx, opts, args) @@ -117,7 +119,7 @@ def handle_parse_result(self, ctx, opts, args): class OrderedGroup(click.Group): - """A click.Group subclass that uses OrderedDict to store commands so the help text lists them + """A click.Group subclass that uses OrderedDict to store commands so the help text lists them in the order they were defined/added to the group. """ diff --git a/src/code42cli/password.py b/src/code42cli/password.py index ed32123e3..6b3492aa7 100644 --- a/src/code42cli/password.py +++ b/src/code42cli/password.py @@ -38,5 +38,7 @@ def _get_keyring_service_name(profile_name): def _prompt_for_alternative_store(): - prompt = "keyring is unavailable. Would you like to store in secure flat file? (y/n): " + prompt = ( + "keyring is unavailable. Would you like to store in secure flat file? (y/n): " + ) return does_user_agree(prompt) diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index 69040485b..22b2e6083 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -2,11 +2,13 @@ import code42cli.password as password from code42cli.cmds.search.cursor_store import get_all_cursor_stores_for_profile -from code42cli.config import ConfigAccessor, config_accessor, NoConfigProfileError +from code42cli.config import config_accessor +from code42cli.config import ConfigAccessor +from code42cli.config import NoConfigProfileError from code42cli.errors import Code42CLIError -class Code42Profile(object): +class Code42Profile: def __init__(self, profile): self._profile = profile @@ -38,7 +40,7 @@ def get_password(self): return pwd def __str__(self): - return "{0}: Username={1}, Authority URL={2}".format( + return "{}: Username={}, Authority URL={}".format( self.name, self.username, self.authority_url ) @@ -79,7 +81,8 @@ def validate_default_profile(): raise Code42CLIError("No existing profile.", help=CREATE_PROFILE_HELP) else: raise Code42CLIError( - "No default profile set.", help=_get_set_default_profile_help(existing_profiles) + "No default profile set.", + help=_get_set_default_profile_help(existing_profiles), ) @@ -118,7 +121,9 @@ def update_profile(name, server, username, ignore_ssl_errors): def get_all_profiles(): - profiles = [Code42Profile(profile) for profile in config_accessor.get_all_profiles()] + profiles = [ + Code42Profile(profile) for profile in config_accessor.get_all_profiles() + ] return profiles @@ -147,9 +152,10 @@ def _get_set_default_profile_help(existing_profiles): To set the default profile (used whenever --profile argument is not provided), use: {} - + Existing profiles: \t{}""".format( - style("code42 profile use ", bold=True), "\n\t".join(existing_profiles) + style("code42 profile use ", bold=True), + "\n\t".join(existing_profiles), ) return help_msg diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index d461bb1ad..85888b69a 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -6,7 +6,8 @@ from py42.exceptions import Py42UnauthorizedError from requests.exceptions import ConnectionError -from code42cli.errors import Code42CLIError, LoggedCLIError +from code42cli.errors import Code42CLIError +from code42cli.errors import LoggedCLIError from code42cli.logger import get_main_cli_logger py42.settings.items_per_page = 500 @@ -19,7 +20,7 @@ def create_sdk(profile, is_debug_mode): py42.settings.debug.level = debug.DEBUG if profile.ignore_ssl_errors == "True": secho( - "Warning: Profile '{0}' has SSL verification disabled. Adding certificate verification " + "Warning: Profile '{}' has SSL verification disabled. Adding certificate verification " "is strongly advised.".format(profile.name), fg="red", err=True, diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 1125bd9b1..b600cd805 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -3,15 +3,19 @@ from collections import OrderedDict from functools import wraps from os import path -from signal import signal, getsignal, SIGINT +from signal import getsignal +from signal import SIGINT +from signal import signal -from click import echo, style, get_current_context +from click import echo +from click import get_current_context +from click import style _PADDING_SIZE = 3 def does_user_agree(prompt): - """Prompts the user and checks if they said yes. If command has the `yes_option` flag, and + """Prompts the user and checks if they said yes. If command has the `yes_option` flag, and `-y/--yes` is passed, this will always return `True`. """ ctx = get_current_context() @@ -24,9 +28,9 @@ def does_user_agree(prompt): def get_user_project_path(*subdirs): """The path on your user dir to /.code42cli/[subdir].""" - package_name = __name__.split(u".")[0] - home = path.expanduser(u"~") - hidden_package_name = u".{0}".format(package_name) + package_name = __name__.split(".")[0] + home = path.expanduser("~") + hidden_package_name = ".{}".format(package_name) user_project_path = path.join(home, hidden_package_name) result_path = path.join(user_project_path, *subdirs) if not path.exists(result_path): @@ -36,14 +40,14 @@ def get_user_project_path(*subdirs): def find_format_width(record, header): """Fetches needed keys/items to be displayed based on header keys. - + Finds the largest string against each column so as to decide the padding size for the column. - + Args: - record (list of dict), data to be formatted. + record (list of dict), data to be formatted. header (dict), key-value where keys should map to keys of record dict and value is the corresponding column name to be displayed on the cli. - + Returns: tuple (list of dict, dict), i.e Filtered records, padding size of columns. """ @@ -80,20 +84,23 @@ def format_string_list_to_columns(string_list, max_width=None): column_width = len(max(string_list, key=len)) + _PADDING_SIZE num_columns = int(max_width / column_width) or 1 format_string = "{{:<{0}}}".format(column_width) * num_columns - batches = [string_list[i : i + num_columns] for i in range(0, len(string_list), num_columns)] + batches = [ + string_list[i : i + num_columns] + for i in range(0, len(string_list), num_columns) + ] padding = ["" for _ in range(num_columns)] for batch in batches: echo(format_string.format(*batch + padding)) echo() -class warn_interrupt(object): +class warn_interrupt: """A context decorator class used to wrap functions where a keyboard interrupt could potentially - leave things in a bad state. Warns the user with provided message and exits when wrapped + leave things in a bad state. Warns the user with provided message and exits when wrapped function is complete. Requires user to ctrl-c a second time to force exit. - + Usage: - + @warn_interrupt(warning="example message") def my_important_func(): pass diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index ec94bdcb8..aa10420e7 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -1,14 +1,16 @@ import queue -from threading import Thread, Lock +from threading import Lock +from threading import Thread from time import sleep -from py42.exceptions import Py42HTTPError, Py42ForbiddenError +from py42.exceptions import Py42ForbiddenError +from py42.exceptions import Py42HTTPError from code42cli.errors import Code42CLIError from code42cli.logger import get_main_cli_logger -class WorkerStats(object): +class WorkerStats: """Stats about the tasks that have run.""" def __init__(self, total): @@ -35,7 +37,7 @@ def total_successes(self): return val if val >= 0 else 0 def __str__(self): - return "{0} succeeded, {1} failed out of {2}".format( + return "{} succeeded, {} failed out of {}".format( self.total_successes, self._total_errors, self.total ) @@ -50,7 +52,7 @@ def increment_total_errors(self): self._total_errors += 1 -class Worker(object): +class Worker: def __init__(self, thread_count, expected_total, bar=None): self._queue = queue.Queue() self._thread_count = thread_count @@ -63,7 +65,7 @@ def __init__(self, thread_count, expected_total, bar=None): def do_async(self, func, *args, **kwargs): """Execute the given func asynchronously given *args and **kwargs. - + Args: func (callable): The function to execute asynchronously. *args (iter): Positional args to pass to the function. @@ -84,7 +86,7 @@ def stats(self): return self._stats def wait(self): - """Wait for the tasks in the queue to complete. This should usually be called before + """Wait for the tasks in the queue to complete. This should usually be called before program termination.""" while self._stats.total_processed < self._tasks: sleep(0.5) diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index c0a45f18a..9d71b67fe 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -2,13 +2,15 @@ import threading import pytest -from requests import Request, Response, HTTPError - from py42.exceptions import Py42BadRequestError from py42.sdk import SDKClient +from requests import HTTPError +from requests import Request +from requests import Response +from tests.conftest import convert_str_to_date + from code42cli import PRODUCT_NAME from code42cli.logger import CliLogger -from tests.conftest import convert_str_to_date @pytest.fixture @@ -35,7 +37,9 @@ def cli_logger(mocker): @pytest.fixture def stdout_logger(mocker): - mock = mocker.patch("{}.cmds.search.logger_factory.get_logger_for_stdout".format(PRODUCT_NAME)) + mock = mocker.patch( + "{}.cmds.search.logger_factory.get_logger_for_stdout".format(PRODUCT_NAME) + ) mock.return_value = mocker.MagicMock() return mock diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index 948b4d4c2..d6813bad2 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -2,7 +2,9 @@ import pytest from py42.exceptions import Py42InternalServerError -from requests import Request, Response, HTTPError +from requests import HTTPError +from requests import Request +from requests import Response from code42cli.main import cli @@ -15,8 +17,8 @@ TEST_SYSTEM_RULE_RESPONSE = { "ruleMetadata": [ { - u"observerRuleId": TEST_RULE_ID, - "type": u"FED_FILE_TYPE_MISMATCH", + "observerRuleId": TEST_RULE_ID, + "type": "FED_FILE_TYPE_MISMATCH", "isSystem": True, "ruleSource": "NOTVALID", } @@ -35,10 +37,14 @@ } TEST_GET_ALL_RESPONSE_EXFILTRATION = { - "ruleMetadata": [{"observerRuleId": TEST_RULE_ID, "type": "FED_ENDPOINT_EXFILTRATION"}] + "ruleMetadata": [ + {"observerRuleId": TEST_RULE_ID, "type": "FED_ENDPOINT_EXFILTRATION"} + ] } TEST_GET_ALL_RESPONSE_CLOUD_SHARE = { - "ruleMetadata": [{"observerRuleId": TEST_RULE_ID, "type": "FED_CLOUD_SHARE_PERMISSIONS"}] + "ruleMetadata": [ + {"observerRuleId": TEST_RULE_ID, "type": "FED_CLOUD_SHARE_PERMISSIONS"} + ] } TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH = { "ruleMetadata": [{"observerRuleId": TEST_RULE_ID, "type": "FED_FILE_TYPE_MISMATCH"}] @@ -75,17 +81,23 @@ def mock_server_error(mocker): def test_add_user_adds_user_list_to_alert_rules(runner, cli_state): - cli_state.sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_USER_ID}]} + cli_state.sdk.users.get_by_username.return_value = { + "users": [{"userUid": TEST_USER_ID}] + } runner.invoke( cli, ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], obj=cli_state, ) - cli_state.sdk.alerts.rules.add_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) + cli_state.sdk.alerts.rules.add_user.assert_called_once_with( + TEST_RULE_ID, TEST_USER_ID + ) def test_add_user_when_non_existent_alert_prints_no_rules_message(runner, cli_state): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_EMPTY_RULE_RESPONSE + ) result = runner.invoke( cli, ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], @@ -98,7 +110,9 @@ def test_add_user_when_non_existent_alert_prints_no_rules_message(runner, cli_st def test_add_user_when_returns_500_and_system_rule_exits_with_InvalidRuleTypeError( runner, cli_state, mock_server_error ): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_SYSTEM_RULE_RESPONSE + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_SYSTEM_RULE_RESPONSE + ) cli_state.sdk.alerts.rules.add_user.side_effect = mock_server_error result = runner.invoke( cli, @@ -128,17 +142,23 @@ def test_add_user_when_returns_500_and_not_system_rule_raises_Py42InternalServer def test_remove_user_removes_user_list_from_alert_rules(runner, cli_state): - cli_state.sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_USER_ID}]} + cli_state.sdk.users.get_by_username.return_value = { + "users": [{"userUid": TEST_USER_ID}] + } runner.invoke( cli, ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], obj=cli_state, ) - cli_state.sdk.alerts.rules.remove_user.assert_called_once_with(TEST_RULE_ID, TEST_USER_ID) + cli_state.sdk.alerts.rules.remove_user.assert_called_once_with( + TEST_RULE_ID, TEST_USER_ID + ) def test_remove_user_when_non_existent_alert_prints_no_rules_message(runner, cli_state): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_EMPTY_RULE_RESPONSE + ) result = runner.invoke( cli, ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], @@ -151,7 +171,9 @@ def test_remove_user_when_non_existent_alert_prints_no_rules_message(runner, cli def test_remove_user_when_returns_500_and_system_rule_raises_InvalidRuleTypeError( runner, cli_state, mock_server_error ): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_SYSTEM_RULE_RESPONSE + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_SYSTEM_RULE_RESPONSE + ) cli_state.sdk.alerts.rules.remove_user.side_effect = mock_server_error result = runner.invoke( cli, @@ -173,7 +195,14 @@ def test_remove_user_when_returns_500_and_not_system_rule_raises_Py42InternalSer with caplog.at_level(logging.ERROR): result = runner.invoke( cli, - ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + [ + "alert-rules", + "remove-user", + "--rule-id", + TEST_RULE_ID, + "-u", + TEST_USERNAME, + ], obj=cli_state, ) assert result.exit_code == 1 @@ -192,13 +221,17 @@ def test_list_when_no_rules_prints_no_rules_message(runner, cli_state): def test_show_rule_calls_correct_rule_property(runner, cli_state): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_GET_ALL_RESPONSE_EXFILTRATION + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_GET_ALL_RESPONSE_EXFILTRATION + ) runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) cli_state.sdk.alerts.rules.exfiltration.get.assert_called_once_with(TEST_RULE_ID) def test_show_rule_calls_correct_rule_property_cloud_share(runner, cli_state): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_GET_ALL_RESPONSE_CLOUD_SHARE + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_GET_ALL_RESPONSE_CLOUD_SHARE + ) runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) cli_state.sdk.alerts.rules.cloudshare.get.assert_called_once_with(TEST_RULE_ID) @@ -208,11 +241,15 @@ def test_show_rule_calls_correct_rule_property_file_type_mismatch(runner, cli_st TEST_GET_ALL_RESPONSE_FILE_TYPE_MISMATCH ) runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) - cli_state.sdk.alerts.rules.filetypemismatch.get.assert_called_once_with(TEST_RULE_ID) + cli_state.sdk.alerts.rules.filetypemismatch.get.assert_called_once_with( + TEST_RULE_ID + ) def test_show_rule_when_no_matching_rule_prints_no_rule_message(runner, cli_state): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_EMPTY_RULE_RESPONSE + cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( + TEST_EMPTY_RULE_RESPONSE + ) result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) msg = "No alert rules with RuleId {} found".format(TEST_RULE_ID) assert msg in result.output @@ -223,7 +260,9 @@ def test_add_bulk_users_uses_expected_arguments(runner, mocker, cli_state): with runner.isolated_filesystem(): with open("test_add.csv", "w") as csv: csv.writelines(["rule_id,username\n", "test,value\n"]) - runner.invoke(cli, ["alert-rules", "bulk", "add", "test_add.csv"], obj=cli_state) + runner.invoke( + cli, ["alert-rules", "bulk", "add", "test_add.csv"], obj=cli_state + ) assert bulk_processor.call_args[0][1] == [{"rule_id": "test", "username": "value"}] diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 22915bdf7..a3758b7f3 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -1,16 +1,14 @@ +import py42.sdk.queries.alerts.filters as f import pytest -from click.testing import CliRunner - from c42eventextractor.extractors import AlertExtractor -from py42.sdk.queries.alerts.filters import * +from tests.cmds.conftest import filter_term_is_in_call_args +from tests.cmds.conftest import get_filter_value_from_json +from tests.conftest import get_test_date_str from code42cli import PRODUCT_NAME -from code42cli.main import cli -from code42cli.cmds.search.cursor_store import AlertCursorStore from code42cli.cmds.search import extraction - -from tests.cmds.conftest import get_filter_value_from_json, filter_term_is_in_call_args -from tests.conftest import get_test_date_str +from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.main import cli BEGIN_TIMESTAMP = 1577858400.0 @@ -21,16 +19,66 @@ ALERT_SUMMARY_LIST = [{"id": i} for i in range(20)] ALERT_DETAIL_RESULT = [ - {"alerts": [{"id": 1, "createdAt": "2020-01-17"}, {"id": 11, "createdAt": "2020-01-18"}]}, - {"alerts": [{"id": 2, "createdAt": "2020-01-19"}, {"id": 12, "createdAt": "2020-01-20"}]}, - {"alerts": [{"id": 3, "createdAt": "2020-01-01"}, {"id": 13, "createdAt": "2020-01-02"}]}, - {"alerts": [{"id": 4, "createdAt": "2020-01-03"}, {"id": 14, "createdAt": "2020-01-04"}]}, - {"alerts": [{"id": 5, "createdAt": "2020-01-05"}, {"id": 15, "createdAt": "2020-01-06"}]}, - {"alerts": [{"id": 6, "createdAt": "2020-01-07"}, {"id": 16, "createdAt": "2020-01-08"}]}, - {"alerts": [{"id": 7, "createdAt": "2020-01-09"}, {"id": 17, "createdAt": "2020-01-10"}]}, - {"alerts": [{"id": 8, "createdAt": "2020-01-11"}, {"id": 18, "createdAt": "2020-01-12"}]}, - {"alerts": [{"id": 9, "createdAt": "2020-01-13"}, {"id": 19, "createdAt": "2020-01-14"}]}, - {"alerts": [{"id": 10, "createdAt": "2020-01-15"}, {"id": 20, "createdAt": "2020-01-16"}]}, + { + "alerts": [ + {"id": 1, "createdAt": "2020-01-17"}, + {"id": 11, "createdAt": "2020-01-18"}, + ] + }, + { + "alerts": [ + {"id": 2, "createdAt": "2020-01-19"}, + {"id": 12, "createdAt": "2020-01-20"}, + ] + }, + { + "alerts": [ + {"id": 3, "createdAt": "2020-01-01"}, + {"id": 13, "createdAt": "2020-01-02"}, + ] + }, + { + "alerts": [ + {"id": 4, "createdAt": "2020-01-03"}, + {"id": 14, "createdAt": "2020-01-04"}, + ] + }, + { + "alerts": [ + {"id": 5, "createdAt": "2020-01-05"}, + {"id": 15, "createdAt": "2020-01-06"}, + ] + }, + { + "alerts": [ + {"id": 6, "createdAt": "2020-01-07"}, + {"id": 16, "createdAt": "2020-01-08"}, + ] + }, + { + "alerts": [ + {"id": 7, "createdAt": "2020-01-09"}, + {"id": 17, "createdAt": "2020-01-10"}, + ] + }, + { + "alerts": [ + {"id": 8, "createdAt": "2020-01-11"}, + {"id": 18, "createdAt": "2020-01-12"}, + ] + }, + { + "alerts": [ + {"id": 9, "createdAt": "2020-01-13"}, + {"id": 19, "createdAt": "2020-01-14"}, + ] + }, + { + "alerts": [ + {"id": 10, "createdAt": "2020-01-15"}, + {"id": 20, "createdAt": "2020-01-16"}, + ] + }, ] SORTED_ALERT_DETAILS = [ @@ -85,7 +133,9 @@ def alert_cursor_without_checkpoint(mocker): @pytest.fixture def begin_option(mocker): - mock = mocker.patch("{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME)) + mock = mocker.patch( + "{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME) + ) mock.return_value = BEGIN_TIMESTAMP mock.expected_timestamp = "2020-01-01T06:00:00.000Z" return mock @@ -104,7 +154,9 @@ def test_search_with_advanced_query_uses_only_the_extract_advanced_method( ): runner.invoke( - cli, ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + cli, + ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON], + obj=cli_state, ) alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') assert alert_extractor.extract.call_count == 0 @@ -140,10 +192,14 @@ def test_search_without_advanced_query_uses_only_the_extract_method( ("--use-checkpoint", "test"), ], ) -def test_search_with_advanced_query_and_incompatible_argument_errors(arg, cli_state, runner): +def test_search_with_advanced_query_and_incompatible_argument_errors( + arg, cli_state, runner +): result = runner.invoke( - cli, ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON, *arg], obj=cli_state, + cli, + ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + obj=cli_state, ) assert result.exit_code == 2 assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output @@ -156,13 +212,15 @@ def test_search_when_given_begin_and_end_dates_uses_expected_query( end_date = get_test_date_str(days_ago=1) runner.invoke( - cli, ["alerts", "search", "--begin", begin_date, "--end", end_date], obj=cli_state + cli, + ["alerts", "search", "--begin", begin_date, "--end", end_date], + obj=cli_state, ) filters = alert_extractor.extract.call_args[0][0] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{0}T00:00:00.000Z".format(begin_date) + expected_begin = "{}T00:00:00.000Z".format(begin_date) actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{0}T23:59:59.999Z".format(end_date) + expected_end = "{}T23:59:59.999Z".format(end_date) assert actual_begin == expected_begin assert actual_end == expected_end @@ -187,9 +245,9 @@ def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( ) filters = alert_extractor.extract.call_args[0][0] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{0}T{1}.000Z".format(begin_date, time) + expected_begin = "{}T{}.000Z".format(begin_date, time) actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{0}T{1}.000Z".format(end_date, time) + expected_end = "{}T{}.000Z".format(end_date, time) assert actual_begin == expected_begin assert actual_end == expected_end @@ -199,11 +257,13 @@ def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_que ): date = get_test_date_str(days_ago=89) time = "15:33" - result = runner.invoke( + runner.invoke( cli, ["alerts", "search", "--begin", "{} {}".format(date, time)], obj=cli_state ) - actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) - expected = "{0}T{1}:00.000Z".format(date, time) + actual = get_filter_value_from_json( + alert_extractor.extract.call_args[0][0], filter_index=0 + ) + expected = "{}T{}:00.000Z".format(date, time) assert actual == expected @@ -215,17 +275,30 @@ def test_search_when_given_end_date_and_time_uses_expected_query( time = "15:33" runner.invoke( cli, - ["alerts", "search", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], + [ + "alerts", + "search", + "--begin", + begin_date, + "--end", + "{} {}".format(end_date, time), + ], obj=cli_state, ) - actual = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=1) - expected = "{0}T{1}:00.000Z".format(end_date, time) + actual = get_filter_value_from_json( + alert_extractor.extract.call_args[0][0], filter_index=1 + ) + expected = "{}T{}:00.000Z".format(end_date, time) assert actual == expected -def test_search_when_given_begin_date_more_than_ninety_days_back_errors(cli_state, runner): +def test_search_when_given_begin_date_more_than_ninety_days_back_errors( + cli_state, runner +): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - result = runner.invoke(cli, ["alerts", "search", "--begin", begin_date], obj=cli_state) + result = runner.invoke( + cli, ["alerts", "search", "--begin", begin_date], obj=cli_state + ) assert result.exit_code == 2 assert "must be within 90 days" in result.output @@ -235,9 +308,11 @@ def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stor ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" runner.invoke( - cli, ["alerts", "search", "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state + cli, + ["alerts", "search", "--begin", begin_date, "--use-checkpoint", "test"], + obj=cli_state, ) - assert not filter_term_is_in_call_args(alert_extractor, DateObserved._term) + assert not filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( @@ -245,17 +320,21 @@ def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_u ): begin_date = get_test_date_str(days_ago=1) runner.invoke(cli, ["alerts", "search", "--begin", begin_date], obj=cli_state) - actual_ts = get_filter_value_from_json(alert_extractor.extract.call_args[0][0], filter_index=0) - expected_ts = "{0}T00:00:00.000Z".format(begin_date) + actual_ts = get_filter_value_from_json( + alert_extractor.extract.call_args[0][0], filter_index=0 + ) + expected_ts = "{}T00:00:00.000Z".format(begin_date) assert actual_ts == expected_ts - assert filter_term_is_in_call_args(alert_extractor, DateObserved._term) + assert filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) def test_search_when_end_date_is_before_begin_date_causes_exit(cli_state, runner): begin_date = get_test_date_str(days_ago=1) end_date = get_test_date_str(days_ago=3) result = runner.invoke( - cli, ["alerts", "search", "--begin", begin_date, "--end", end_date], obj=cli_state + cli, + ["alerts", "search", "--begin", begin_date, "--end", end_date], + obj=cli_state, ) assert result.exit_code == 2 assert "'--begin': cannot be after --end date" in result.output @@ -284,7 +363,7 @@ def test_search_with_only_begin_calls_extract_with_expected_filters( assert result.exit_code == 0 assert str( alert_extractor.extract.call_args[0][0] - ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"createdAt", ' '"value":"{}"}}]}}'.format( + ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"createdAt", "value":"{}"}}]}}'.format( begin_option.expected_timestamp ) @@ -292,7 +371,9 @@ def test_search_with_only_begin_calls_extract_with_expected_filters( def test_search_with_use_checkpoint_and_without_begin_and_without_stored_checkpoint_causes_expected_error( cli_state, alert_cursor_without_checkpoint, runner ): - result = runner.invoke(cli, ["alerts", "search", "--use-checkpoint", "test"], obj=cli_state) + result = runner.invoke( + cli, ["alerts", "search", "--use-checkpoint", "test"], obj=cli_state + ) assert result.exit_code == 2 assert ( "--begin date is required for --use-checkpoint when no checkpoint exists yet." @@ -310,12 +391,21 @@ def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract ): result = runner.invoke( cli, - ["alerts", "search", "--use-checkpoint", "test", "--begin", ""], + [ + "alerts", + "search", + "--use-checkpoint", + "test", + "--begin", + "", + ], obj=cli_state, ) assert result.exit_code == 0 assert len(alert_extractor.extract.call_args[0]) == 1 - assert begin_option.expected_timestamp in str(alert_extractor.extract.call_args[0][0]) + assert begin_option.expected_timestamp in str( + alert_extractor.extract.call_args[0][0] + ) def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( @@ -323,44 +413,58 @@ def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_ca ): result = runner.invoke( - cli, ["alerts", "search", "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + cli, + ["alerts", "search", "--use-checkpoint", "test", "--begin", "1h"], + obj=cli_state, ) assert result.exit_code == 0 alert_extractor.extract.assert_called_with() assert ( - "checkpoint of {} exists".format(alert_cursor_with_checkpoint.expected_timestamp) + "checkpoint of {} exists".format( + alert_cursor_with_checkpoint.expected_timestamp + ) in result.output ) -def test_search_when_given_actor_is_uses_username_filter(cli_state, alert_extractor, runner): +def test_search_when_given_actor_is_uses_username_filter( + cli_state, alert_extractor, runner +): actor_name = "test.testerson" runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--actor", actor_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(Actor.is_in([actor_name])) in filter_strings + assert str(f.Actor.is_in([actor_name])) in filter_strings -def test_search_when_given_exclude_actor_uses_actor_filter(cli_state, alert_extractor, runner): +def test_search_when_given_exclude_actor_uses_actor_filter( + cli_state, alert_extractor, runner +): actor_name = "test.testerson" runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--exclude-actor", actor_name], obj=cli_state + cli, + ["alerts", "search", "--begin", "1h", "--exclude-actor", actor_name], + obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(Actor.not_in([actor_name])) in filter_strings + assert str(f.Actor.not_in([actor_name])) in filter_strings -def test_search_when_given_rule_name_uses_rule_name_filter(cli_state, alert_extractor, runner): +def test_search_when_given_rule_name_uses_rule_name_filter( + cli_state, alert_extractor, runner +): rule_name = "departing employee" runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--rule-name", rule_name], obj=cli_state + cli, + ["alerts", "search", "--begin", "1h", "--rule-name", rule_name], + obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(RuleName.is_in([rule_name])) in filter_strings + assert str(f.RuleName.is_in([rule_name])) in filter_strings def test_search_when_given_exclude_rule_name_uses_rule_name_not_filter( @@ -369,20 +473,26 @@ def test_search_when_given_exclude_rule_name_uses_rule_name_not_filter( rule_name = "departing employee" runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-name", rule_name], obj=cli_state + cli, + ["alerts", "search", "--begin", "1h", "--exclude-rule-name", rule_name], + obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(RuleName.not_in([rule_name])) in filter_strings + assert str(f.RuleName.not_in([rule_name])) in filter_strings -def test_search_when_given_rule_type_uses_rule_name_filter(cli_state, alert_extractor, runner): +def test_search_when_given_rule_type_uses_rule_name_filter( + cli_state, alert_extractor, runner +): rule_type = "FedEndpointExfiltration" runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--rule-type", rule_type], obj=cli_state + cli, + ["alerts", "search", "--begin", "1h", "--rule-type", rule_type], + obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(RuleType.is_in([rule_type])) in filter_strings + assert str(f.RuleType.is_in([rule_type])) in filter_strings def test_search_when_given_exclude_rule_type_uses_rule_name_not_filter( @@ -391,20 +501,24 @@ def test_search_when_given_exclude_rule_type_uses_rule_name_not_filter( rule_type = "FedEndpointExfiltration" runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-type", rule_type], obj=cli_state + cli, + ["alerts", "search", "--begin", "1h", "--exclude-rule-type", rule_type], + obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(RuleType.not_in([rule_type])) in filter_strings + assert str(f.RuleType.not_in([rule_type])) in filter_strings -def test_search_when_given_rule_id_uses_rule_name_filter(cli_state, alert_extractor, runner): +def test_search_when_given_rule_id_uses_rule_name_filter( + cli_state, alert_extractor, runner +): rule_id = "departing employee" runner.invoke( cli, ["alerts", "search", "--begin", "1h", "--rule-id", rule_id], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(RuleId.is_in([rule_id])) in filter_strings + assert str(f.RuleId.is_in([rule_id])) in filter_strings def test_search_when_given_exclude_rule_id_uses_rule_name_not_filter( @@ -413,20 +527,26 @@ def test_search_when_given_exclude_rule_id_uses_rule_name_not_filter( rule_id = "departing employee" runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--exclude-rule-id", rule_id], obj=cli_state + cli, + ["alerts", "search", "--begin", "1h", "--exclude-rule-id", rule_id], + obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(RuleId.not_in([rule_id])) in filter_strings + assert str(f.RuleId.not_in([rule_id])) in filter_strings -def test_search_when_given_description_uses_description_filter(cli_state, alert_extractor, runner): +def test_search_when_given_description_uses_description_filter( + cli_state, alert_extractor, runner +): description = "test description" runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--description", description], obj=cli_state + cli, + ["alerts", "search", "--begin", "1h", "--description", description], + obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(Description.contains(description)) in filter_strings + assert str(f.Description.contains(description)) in filter_strings def test_search_when_given_multiple_search_args_uses_expected_filters( @@ -453,6 +573,6 @@ def test_search_when_given_multiple_search_args_uses_expected_filters( obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(Actor.is_in([actor])) in filter_strings - assert str(Actor.not_in([exclude_actor])) in filter_strings - assert str(RuleName.is_in([rule_name])) in filter_strings + assert str(f.Actor.is_in([actor])) in filter_strings + assert str(f.Actor.not_in([exclude_actor])) in filter_strings + assert str(f.RuleName.is_in([rule_name])) in filter_strings diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 315827da4..b6b935b9a 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -1,13 +1,15 @@ -from code42cli.main import cli - -from tests.conftest import TEST_ID from tests.cmds.conftest import thread_safe_side_effect +from tests.conftest import TEST_ID + +from code42cli.main import cli _EMPLOYEE = "departing employee" -def test_add_departing_employee_when_given_cloud_alias_adds_alias(runner, cli_state_with_user): +def test_add_departing_employee_when_given_cloud_alias_adds_alias( + runner, cli_state_with_user +): alias = "departing employee alias" runner.invoke( cli, @@ -24,9 +26,13 @@ def test_add_departing_employee_when_given_notes_updates_notes( ): notes = "is leaving" runner.invoke( - cli, ["departing-employee", "add", _EMPLOYEE, "--notes", notes], obj=cli_state_with_user, + cli, + ["departing-employee", "add", _EMPLOYEE, "--notes", notes], + obj=cli_state_with_user, + ) + cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with( + TEST_ID, notes ) - cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) def test_add_departing_employee_adds( @@ -43,7 +49,9 @@ def test_add_departing_employee_adds( ) -def test_add_departing_employee_when_user_does_not_exist_exits(runner, cli_state_without_user): +def test_add_departing_employee_when_user_does_not_exist_exits( + runner, cli_state_without_user +): result = runner.invoke( cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_without_user ) @@ -57,7 +65,9 @@ def test_add_departing_employee_when_user_already_added_raises_UserAlreadyAddedE cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = ( bad_request_for_user_already_added ) - result = runner.invoke(cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + result = runner.invoke( + cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_with_user + ) assert result.exit_code == 1 assert "'{}' is already on the departing-employee list.".format(_EMPLOYEE) @@ -65,8 +75,12 @@ def test_add_departing_employee_when_user_already_added_raises_UserAlreadyAddedE def test_add_departing_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( runner, cli_state_with_user, generic_bad_request ): - cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = generic_bad_request - result = runner.invoke(cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = ( + generic_bad_request + ) + result = runner.invoke( + cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_with_user + ) assert result.exit_code == 1 assert "Problem making request to server." in result.output assert "View details in" in result.output @@ -81,7 +95,9 @@ def test_remove_departing_employee_calls_remove(runner, cli_state_with_user): ) -def test_remove_departing_employee_when_user_does_not_exist_exits(runner, cli_state_without_user): +def test_remove_departing_employee_when_user_does_not_exist_exits( + runner, cli_state_without_user +): result = runner.invoke( cli, ["departing-employee", "remove", _EMPLOYEE], obj=cli_state_without_user ) @@ -117,7 +133,9 @@ def test_add_bulk_users_calls_expected_py42_methods(runner, mocker, cli_state): assert "2020-02-01" in de_add_user_call_args assert None in de_add_user_call_args - add_user_cloud_alias_call_args = [call[1] for call in add_user_cloud_alias.call_args_list] + add_user_cloud_alias_call_args = [ + call[1] for call in add_user_cloud_alias.call_args_list + ] assert add_user_cloud_alias.call_count == 2 assert "test_alias" in add_user_cloud_alias_call_args assert "test_alias_2" in add_user_cloud_alias_call_args diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 18d735d1a..b66fdc755 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -1,20 +1,26 @@ -from code42cli.main import cli - -from tests.conftest import TEST_ID from tests.cmds.conftest import thread_safe_side_effect +from tests.conftest import TEST_ID + +from code42cli.main import cli _NAMESPACE = "code42cli.cmds.high_risk_employee" _EMPLOYEE = "risky employee" def test_add_high_risk_employee_adds(runner, cli_state_with_user): - runner.invoke(cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user) - cli_state_with_user.sdk.detectionlists.high_risk_employee.add.assert_called_once_with(TEST_ID) + runner.invoke( + cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user + ) + cli_state_with_user.sdk.detectionlists.high_risk_employee.add.assert_called_once_with( + TEST_ID + ) -def test_add_high_risk_employee_when_given_cloud_alias_adds_alias(runner, cli_state_with_user): +def test_add_high_risk_employee_when_given_cloud_alias_adds_alias( + runner, cli_state_with_user +): alias = "risk employee alias" - result = runner.invoke( + runner.invoke( cli, ["high-risk-employee", "add", _EMPLOYEE, "--cloud-alias", alias], obj=cli_state_with_user, @@ -24,7 +30,9 @@ def test_add_high_risk_employee_when_given_cloud_alias_adds_alias(runner, cli_st ) -def test_add_high_risk_employee_when_given_risk_tags_adds_tags(runner, cli_state_with_user): +def test_add_high_risk_employee_when_given_risk_tags_adds_tags( + runner, cli_state_with_user +): runner.invoke( cli, [ @@ -41,16 +49,23 @@ def test_add_high_risk_employee_when_given_risk_tags_adds_tags(runner, cli_state obj=cli_state_with_user, ) cli_state_with_user.sdk.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, ("FLIGHT_RISK", "ELEVATED_ACCESS_PRIVILEGES", "POOR_SECURITY_PRACTICES") + TEST_ID, + ("FLIGHT_RISK", "ELEVATED_ACCESS_PRIVILEGES", "POOR_SECURITY_PRACTICES"), ) -def test_add_high_risk_employee_when_given_notes_updates_notes(runner, cli_state_with_user): +def test_add_high_risk_employee_when_given_notes_updates_notes( + runner, cli_state_with_user +): notes = "being risky" runner.invoke( - cli, ["high-risk-employee", "add", _EMPLOYEE, "--notes", notes], obj=cli_state_with_user, + cli, + ["high-risk-employee", "add", _EMPLOYEE, "--notes", notes], + obj=cli_state_with_user, + ) + cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with( + TEST_ID, notes ) - cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with(TEST_ID, notes) def test_add_high_risk_employee_when_user_does_not_exist_exits_with_correct_message( @@ -69,16 +84,25 @@ def test_add_high_risk_employee_when_user_already_added_exits_with_correct_messa cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = ( bad_request_for_user_already_added ) - result = runner.invoke(cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + result = runner.invoke( + cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user + ) assert result.exit_code == 1 - assert "'{}' is already on the high-risk-employee list.".format(_EMPLOYEE) in result.output + assert ( + "'{}' is already on the high-risk-employee list.".format(_EMPLOYEE) + in result.output + ) def test_add_high_risk_employee_when_bad_request_but_not_user_already_added_exits_with_message_to_see_logs( runner, cli_state_with_user, generic_bad_request ): - cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = generic_bad_request - result = runner.invoke(cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user) + cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = ( + generic_bad_request + ) + result = runner.invoke( + cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user + ) assert result.exit_code == 1 assert "Problem making request to server." in result.output assert "View details in" in result.output @@ -160,17 +184,24 @@ def test_bulk_remove_employees_uses_expected_arguments(runner, cli_state, mocker with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: csv.writelines(["# username\n", "test@example.com\n", "test2@example.com"]) - result = runner.invoke( - cli, ["high-risk-employee", "bulk", "remove", "test_remove.csv"], obj=cli_state + runner.invoke( + cli, + ["high-risk-employee", "bulk", "remove", "test_remove.csv"], + obj=cli_state, ) - assert bulk_processor.call_args[0][1] == ["test@example.com", "test2@example.com"] + assert bulk_processor.call_args[0][1] == [ + "test@example.com", + "test2@example.com", + ] def test_bulk_add_risk_tags_uses_expected_arguments(runner, cli_state, mocker): bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) with runner.isolated_filesystem(): with open("test_add_risk_tags.csv", "w") as csv: - csv.writelines(["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"]) + csv.writelines( + ["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"] + ) runner.invoke( cli, ["high-risk-employee", "bulk", "add-risk-tags", "test_add_risk_tags.csv"], @@ -186,10 +217,17 @@ def test_bulk_remove_risk_tags_uses_expected_arguments(runner, cli_state, mocker bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) with runner.isolated_filesystem(): with open("test_remove_risk_tags.csv", "w") as csv: - csv.writelines(["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"]) + csv.writelines( + ["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"] + ) runner.invoke( cli, - ["high-risk-employee", "bulk", "remove-risk-tags", "test_remove_risk_tags.csv"], + [ + "high-risk-employee", + "bulk", + "remove-risk-tags", + "test_remove_risk_tags.csv", + ], obj=cli_state, ) assert bulk_processor.call_args[0][1] == [ diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index de7761d24..cd79d88ba 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -1,5 +1,8 @@ import pytest -from requests import Response, HTTPError +from py42.exceptions import Py42BadRequestError +from py42.response import Py42Response +from requests import HTTPError +from requests import Response from code42cli import PRODUCT_NAME from code42cli.cmds.legal_hold import _check_matter_is_accessible @@ -7,10 +10,6 @@ _NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) -from py42.exceptions import Py42BadRequestError -from py42.response import Py42Response - - TEST_MATTER_ID = "99999" TEST_LEGAL_HOLD_MEMBERSHIP_UID = "88888" TEST_LEGAL_HOLD_MEMBERSHIP_UID_2 = "77777" @@ -44,9 +43,16 @@ EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": []}] -ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP]}] +ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ + {"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP]} +] ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ - {"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP, INACTIVE_LEGAL_HOLD_MEMBERSHIP]} + { + "legalHoldMemberships": [ + ACTIVE_LEGAL_HOLD_MEMBERSHIP, + INACTIVE_LEGAL_HOLD_MEMBERSHIP, + ] + } ] INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ {"legalHoldMemberships": [INACTIVE_LEGAL_HOLD_MEMBERSHIP]} @@ -67,7 +73,9 @@ def preservation_policy_response(mocker): @pytest.fixture def get_user_id_success(cli_state): - cli_state.sdk.users.get_by_username.return_value = {"users": [{"userUid": ACTIVE_TEST_USER_ID}]} + cli_state.sdk.users.get_by_username.return_value = { + "users": [{"userUid": ACTIVE_TEST_USER_ID}] + } @pytest.fixture @@ -82,7 +90,9 @@ def check_matter_accessible_success(cli_state): @pytest.fixture def check_matter_accessible_failure(cli_state): - cli_state.sdk.legalhold.get_matter_by_uid.side_effect = Py42BadRequestError(HTTPError()) + cli_state.sdk.legalhold.get_matter_by_uid.side_effect = Py42BadRequestError( + HTTPError() + ) @pytest.fixture @@ -112,7 +122,7 @@ def test_add_user_raises_user_already_added_error_when_user_already_on_hold( obj=cli_state, ) assert result.exit_code == 1 - assert "'{0}' is already on the legal hold matter id={1}".format( + assert "'{}' is already on the legal hold matter id={}".format( ACTIVE_TEST_USERNAME, TEST_MATTER_ID ) @@ -133,7 +143,7 @@ def test_add_user_raises_legalhold_not_found_error_if_matter_inaccessible( obj=cli_state, ) assert result.exit_code == 1 - assert "Matter with id={0} either does not exist or your profile does not have permission to view it.".format( + assert "Matter with id={} either does not exist or your profile does not have permission to view it.".format( TEST_MATTER_ID ) @@ -174,8 +184,10 @@ def test_remove_user_raises_legalhold_not_found_error_if_matter_inaccessible( obj=cli_state, ) assert result.exit_code == 1 - assert "Matter with id={0} either does not exist or your profile does not have " - "permission to view it.".format(TEST_MATTER_ID) + assert ( + "Matter with id={} either does not exist or your profile does not have " + "permission to view it.".format(TEST_MATTER_ID) + ) def test_remove_user_raises_user_not_in_matter_error_if_user_not_active_in_matter( @@ -197,7 +209,7 @@ def test_remove_user_raises_user_not_in_matter_error_if_user_not_active_in_matte obj=cli_state, ) assert result.exit_code == 1 - assert "User '{0}' is not an active member of legal hold matter '{1}'".format( + assert "User '{}' is not an active member of legal hold matter '{}'".format( ACTIVE_TEST_USERNAME, TEST_MATTER_ID ) @@ -250,7 +262,9 @@ def test_show_matter_prints_active_and_inactive_results_when_include_inactive_fl assert INACTIVE_TEST_USERNAME in result.output -def test_show_matter_prints_active_results_only(runner, cli_state, check_matter_accessible_success): +def test_show_matter_prints_active_results_only( + runner, cli_state, check_matter_accessible_success +): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT ) @@ -316,7 +330,9 @@ def test_show_matter_prints_no_active_members_when_no_active_membership_and_inac def test_show_matter_prints_preservation_policy_when_include_policy_flag_set( runner, cli_state, check_matter_accessible_success, preservation_policy_response ): - cli_state.sdk.legalhold.get_policy_by_uid.return_value = preservation_policy_response + cli_state.sdk.legalhold.get_policy_by_uid.return_value = ( + preservation_policy_response + ) result = runner.invoke( cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-policy"], obj=cli_state ) @@ -326,7 +342,9 @@ def test_show_matter_prints_preservation_policy_when_include_policy_flag_set( def test_show_matter_does_not_print_preservation_policy( runner, cli_state, check_matter_accessible_success, preservation_policy_response ): - cli_state.sdk.legalhold.get_policy_by_uid.return_value = preservation_policy_response + cli_state.sdk.legalhold.get_policy_by_uid.return_value = ( + preservation_policy_response + ) result = runner.invoke(cli, ["legal-hold", "show", TEST_MATTER_ID], obj=cli_state) assert TEST_PRESERVATION_POLICY_UID not in result.output @@ -337,7 +355,9 @@ def test_add_bulk_users_uses_expected_arguments(runner, mocker, cli_state): with open("test_add.csv", "w") as csv: csv.writelines(["matter_id,username\n", "test,value\n"]) runner.invoke(cli, ["legal-hold", "bulk", "add", "test_add.csv"], obj=cli_state) - assert bulk_processor.call_args[0][1] == [{"matter_id": "test", "username": "value"}] + assert bulk_processor.call_args[0][1] == [ + {"matter_id": "test", "username": "value"} + ] def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): @@ -348,4 +368,6 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): runner.invoke( cli, ["legal-hold", "bulk", "remove", "test_remove.csv"], obj=cli_state ) - assert bulk_processor.call_args[0][1] == [{"matter_id": "test", "username": "value"}] + assert bulk_processor.call_args[0][1] == [ + {"matter_id": "test", "username": "value"} + ] diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 9a30c4eb9..110a04615 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,9 +1,10 @@ import pytest +from ..conftest import create_mock_profile from code42cli import PRODUCT_NAME -from code42cli.errors import Code42CLIError, LoggedCLIError +from code42cli.errors import Code42CLIError +from code42cli.errors import LoggedCLIError from code42cli.main import cli -from ..conftest import create_mock_profile @pytest.fixture @@ -75,9 +76,22 @@ def test_create_profile_if_user_sets_password_is_created( ): mock_cliprofile_namespace.profile_exists.return_value = False runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + cli, + [ + "profile", + "create", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + ], + ) + mock_cliprofile_namespace.create_profile.assert_called_once_with( + "foo", "bar", "baz", True ) - mock_cliprofile_namespace.create_profile.assert_called_once_with("foo", "bar", "baz", True) def test_create_profile_if_user_does_not_set_password_is_created( @@ -85,9 +99,22 @@ def test_create_profile_if_user_does_not_set_password_is_created( ): mock_cliprofile_namespace.profile_exists.return_value = False runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + cli, + [ + "profile", + "create", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + ], + ) + mock_cliprofile_namespace.create_profile.assert_called_once_with( + "foo", "bar", "baz", True ) - mock_cliprofile_namespace.create_profile.assert_called_once_with("foo", "bar", "baz", True) def test_create_profile_if_user_does_not_agree_does_not_save_password( @@ -95,7 +122,18 @@ def test_create_profile_if_user_does_not_agree_does_not_save_password( ): mock_cliprofile_namespace.profile_exists.return_value = False runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + cli, + [ + "profile", + "create", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + ], ) assert not mock_cliprofile_namespace.set_password.call_count @@ -104,7 +142,9 @@ def test_create_profile_if_credentials_invalid_password_not_saved( runner, user_agreement, invalid_connection, mock_cliprofile_namespace ): mock_cliprofile_namespace.profile_exists.return_value = False - result = runner.invoke(cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz"],) + result = runner.invoke( + cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz"], + ) assert "Password not stored!" in result.output assert not mock_cliprofile_namespace.set_password.call_count @@ -115,7 +155,19 @@ def test_create_profile_with_password_option_if_credentials_invalid_password_not password = "test_pass" mock_cliprofile_namespace.profile_exists.return_value = False result = runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--password", password], + cli, + [ + "profile", + "create", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--password", + password, + ], ) assert "Password not stored!" in result.output assert not mock_cliprofile_namespace.set_password.call_count @@ -127,7 +179,9 @@ def test_create_profile_if_credentials_valid_password_saved( ): mock_cliprofile_namespace.profile_exists.return_value = False runner.invoke(cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz"]) - mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) + mock_cliprofile_namespace.set_password.assert_called_once_with( + "newpassword", mocker.ANY + ) def test_create_profile_with_password_option_if_credentials_valid_password_saved( @@ -136,7 +190,19 @@ def test_create_profile_with_password_option_if_credentials_valid_password_saved password = "test_pass" mock_cliprofile_namespace.profile_exists.return_value = False result = runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--password", password], + cli, + [ + "profile", + "create", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--password", + password, + ], ) mock_cliprofile_namespace.set_password.assert_called_once_with(password, mocker.ANY) assert "Would you like to set a password?" not in result.output @@ -147,7 +213,18 @@ def test_create_profile_outputs_confirmation( ): mock_cliprofile_namespace.profile_exists.return_value = False result = runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + cli, + [ + "profile", + "create", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + ], ) assert "Successfully created profile 'foo'." in result.output @@ -159,9 +236,22 @@ def test_update_profile_updates_existing_profile( profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile runner.invoke( - cli, ["profile", "update", "-n", name, "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + ], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, "bar", "baz", True ) - mock_cliprofile_namespace.update_profile.assert_called_once_with(name, "bar", "baz", True) def test_update_profile_if_user_does_not_agree_does_not_save_password( @@ -171,7 +261,18 @@ def test_update_profile_if_user_does_not_agree_does_not_save_password( profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile runner.invoke( - cli, ["profile", "update", "-n", name, "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + ], ) assert not mock_cliprofile_namespace.set_password.call_count @@ -184,7 +285,18 @@ def test_update_profile_if_credentials_invalid_password_not_saved( mock_cliprofile_namespace.get_profile.return_value = profile result = runner.invoke( - cli, ["profile", "update", "-n", "foo", "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + cli, + [ + "profile", + "update", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + ], ) assert not mock_cliprofile_namespace.set_password.call_count assert "Password not stored!" in result.output @@ -197,9 +309,22 @@ def test_update_profile_if_user_agrees_and_valid_connection_sets_password( profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile runner.invoke( - cli, ["profile", "update", "-n", name, "-s", "bar", "-u", "baz", "--disable-ssl-errors"] + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + ], + ) + mock_cliprofile_namespace.set_password.assert_called_once_with( + "newpassword", mocker.ANY ) - mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) def test_delete_profile_warns_if_deleting_default(runner, mock_cliprofile_namespace): @@ -215,7 +340,9 @@ def test_delete_profile_does_nothing_if_user_doesnt_agree( assert mock_cliprofile_namespace.delete_profile.call_count == 0 -def test_delete_profile_outputs_success(runner, mock_cliprofile_namespace, user_agreement): +def test_delete_profile_outputs_success( + runner, mock_cliprofile_namespace, user_agreement +): result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) assert "Profile 'mockdefault' has been deleted." in result.output @@ -237,7 +364,9 @@ def test_delete_all_does_not_warn_if_assume_yes_flag(runner, mock_cliprofile_nam create_mock_profile("test2"), ] result = runner.invoke(cli, ["profile", "delete-all", "-y"]) - assert "Are you sure you want to delete the following profiles?" not in result.output + assert ( + "Are you sure you want to delete the following profiles?" not in result.output + ) assert "Profile '{}' has been deleted.".format("test1") in result.output assert "Profile '{}' has been deleted.".format("test2") in result.output @@ -267,7 +396,9 @@ def test_prompt_for_password_reset_if_credentials_valid_password_saved( mock_verify.return_value = True mock_cliprofile_namespace.profile_exists.return_value = False runner.invoke(cli, ["profile", "reset-pw"]) - mock_cliprofile_namespace.set_password.assert_called_once_with("newpassword", mocker.ANY) + mock_cliprofile_namespace.set_password.assert_called_once_with( + "newpassword", mocker.ANY + ) def test_prompt_for_password_reset_if_credentials_invalid_password_not_saved( @@ -302,5 +433,9 @@ def test_list_profiles_when_no_profiles_outputs_no_profiles_message( def test_use_profile(runner, mock_cliprofile_namespace, profile): result = runner.invoke(cli, ["profile", "use", profile.name]) - mock_cliprofile_namespace.switch_default_profile.assert_called_once_with(profile.name) - assert "{} has been set as the default profile.".format(profile.name) in result.output + mock_cliprofile_namespace.switch_default_profile.assert_called_once_with( + profile.name + ) + assert ( + "{} has been set as the default profile.".format(profile.name) in result.output + ) diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index ef9ba7df2..8a801738b 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -1,15 +1,17 @@ import logging +import py42.sdk.queries.fileevents.filters as f import pytest from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.file_event_query import FileEventQuery -from py42.sdk.queries.fileevents.filters import * +from tests.cmds.conftest import filter_term_is_in_call_args +from tests.cmds.conftest import get_filter_value_from_json +from tests.conftest import get_test_date_str -from code42cli import PRODUCT_NAME, errors +from code42cli import errors +from code42cli import PRODUCT_NAME from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.main import cli -from tests.cmds.conftest import get_filter_value_from_json, filter_term_is_in_call_args -from tests.conftest import get_test_date_str BEGIN_TIMESTAMP = 1577858400.0 END_TIMESTAMP = 1580450400.0 @@ -18,14 +20,18 @@ @pytest.fixture def file_event_extractor(mocker): - mock = mocker.patch("{}.cmds.securitydata._get_file_event_extractor".format(PRODUCT_NAME)) + mock = mocker.patch( + "{}.cmds.securitydata._get_file_event_extractor".format(PRODUCT_NAME) + ) mock.return_value = mocker.MagicMock(spec=FileEventExtractor) return mock.return_value @pytest.fixture def file_event_cursor_with_checkpoint(mocker): - mock = mocker.patch("{}.cmds.securitydata._get_file_event_cursor_store".format(PRODUCT_NAME)) + mock = mocker.patch( + "{}.cmds.securitydata._get_file_event_cursor_store".format(PRODUCT_NAME) + ) mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) mock_cursor.get.return_value = CURSOR_TIMESTAMP mock.return_value = mock_cursor @@ -35,7 +41,9 @@ def file_event_cursor_with_checkpoint(mocker): @pytest.fixture def file_event_cursor_without_checkpoint(mocker): - mock = mocker.patch("{}.cmds.securitydata._get_file_event_cursor_store".format(PRODUCT_NAME)) + mock = mocker.patch( + "{}.cmds.securitydata._get_file_event_cursor_store".format(PRODUCT_NAME) + ) mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) mock_cursor.get.return_value = None mock.return_value = mock_cursor @@ -44,7 +52,9 @@ def file_event_cursor_without_checkpoint(mocker): @pytest.fixture def begin_option(mocker): - mock = mocker.patch("{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME)) + mock = mocker.patch( + "{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME) + ) mock.return_value = BEGIN_TIMESTAMP mock.expected_timestamp = "2020-01-01T06:00:00.000Z" return mock @@ -57,9 +67,13 @@ def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( runner, cli_state, file_event_extractor ): runner.invoke( - cli, ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + cli, + ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON], + obj=cli_state, + ) + file_event_extractor.extract_advanced.assert_called_once_with( + '{"some": "complex json"}' ) - file_event_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') assert file_event_extractor.extract.call_count == 0 assert file_event_extractor.extract_advanced.call_count == 1 @@ -91,7 +105,9 @@ def test_search_when_is_not_advanced_query_uses_only_the_extract_advanced_method ("--use-checkpoint", "test"), ], ) -def test_search_with_advanced_query_and_incompatible_argument_errors(runner, arg, cli_state): +def test_search_with_advanced_query_and_incompatible_argument_errors( + runner, arg, cli_state +): result = runner.invoke( cli, ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON, *arg], @@ -120,9 +136,13 @@ def test_search_with_advanced_query_and_incompatible_argument_errors(runner, arg ("--use-checkpoint", "test"), ], ) -def test_search_with_saved_search_and_incompatible_argument_errors(runner, arg, cli_state): +def test_search_with_saved_search_and_incompatible_argument_errors( + runner, arg, cli_state +): result = runner.invoke( - cli, ["security-data", "search", "--saved-search", "test_id", *arg], obj=cli_state, + cli, + ["security-data", "search", "--saved-search", "test_id", *arg], + obj=cli_state, ) assert result.exit_code == 2 assert "{} can't be used with: --saved-search".format(arg[0]) in result.output @@ -134,13 +154,15 @@ def test_search_when_given_begin_and_end_dates_uses_expected_query( begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) runner.invoke( - cli, ["security-data", "search", "--begin", begin_date, "--end", end_date], obj=cli_state + cli, + ["security-data", "search", "--begin", begin_date, "--end", end_date], + obj=cli_state, ) filters = file_event_extractor.extract.call_args[0][1] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{0}T00:00:00.000Z".format(begin_date) + expected_begin = "{}T00:00:00.000Z".format(begin_date) actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{0}T23:59:59.999Z".format(end_date) + expected_end = "{}T23:59:59.999Z".format(end_date) assert actual_begin == expected_begin assert actual_end == expected_end @@ -165,9 +187,9 @@ def test_search_when_given_begin_and_end_date_and_time_uses_expected_query( ) filters = file_event_extractor.extract.call_args[0][1] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{0}T{1}.000Z".format(begin_date, time) + expected_begin = "{}T{}.000Z".format(begin_date, time) actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{0}T{1}.000Z".format(end_date, time) + expected_end = "{}T{}.000Z".format(end_date, time) assert actual_begin == expected_begin assert actual_end == expected_end @@ -178,12 +200,14 @@ def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_que date = get_test_date_str(days_ago=89) time = "15:33" runner.invoke( - cli, ["security-data", "search", "--begin", "{} {}".format(date, time)], obj=cli_state + cli, + ["security-data", "search", "--begin", "{} {}".format(date, time)], + obj=cli_state, ) actual = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 ) - expected = "{0}T{1}:00.000Z".format(date, time) + expected = "{}T{}:00.000Z".format(date, time) assert actual == expected @@ -195,13 +219,20 @@ def test_search_when_given_end_date_and_time_uses_expected_query( time = "15:33" runner.invoke( cli, - ["security-data", "search", "--begin", begin_date, "--end", "{} {}".format(end_date, time)], + [ + "security-data", + "search", + "--begin", + begin_date, + "--end", + "{} {}".format(end_date, time), + ], obj=cli_state, ) actual = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=1 ) - expected = "{0}T{1}:00.000Z".format(end_date, time) + expected = "{}T{}:00.000Z".format(end_date, time) assert actual == expected @@ -209,7 +240,9 @@ def test_search_when_given_begin_date_more_than_ninety_days_back_errors( runner, cli_state, ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - result = runner.invoke(cli, ["security-data", "search", "--begin", begin_date], obj=cli_state) + result = runner.invoke( + cli, ["security-data", "search", "--begin", begin_date], obj=cli_state + ) assert result.exit_code == 2 assert "must be within 90 days" in result.output @@ -223,27 +256,33 @@ def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stor ["security-data", "search", "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state, ) - assert not filter_term_is_in_call_args(file_event_extractor, InsertionTimestamp._term) + assert not filter_term_is_in_call_args( + file_event_extractor, f.InsertionTimestamp._term + ) def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( runner, cli_state, file_event_extractor ): begin_date = get_test_date_str(days_ago=1) - runner.invoke(cli, ["security-data", "search", "--begin", begin_date], obj=cli_state) + runner.invoke( + cli, ["security-data", "search", "--begin", begin_date], obj=cli_state + ) actual_ts = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 ) - expected_ts = "{0}T00:00:00.000Z".format(begin_date) + expected_ts = "{}T00:00:00.000Z".format(begin_date) assert actual_ts == expected_ts - assert filter_term_is_in_call_args(file_event_extractor, EventTimestamp._term) + assert filter_term_is_in_call_args(file_event_extractor, f.EventTimestamp._term) def test_search_when_end_date_is_before_begin_date_causes_exit(runner, cli_state): begin_date = get_test_date_str(days_ago=1) end_date = get_test_date_str(days_ago=3) result = runner.invoke( - cli, ["security-data", "search", "--begin", begin_date, "--end", end_date], obj=cli_state + cli, + ["security-data", "search", "--begin", begin_date, "--end", end_date], + obj=cli_state, ) assert result.exit_code == 2 assert "'--begin': cannot be after --end date" in result.output @@ -252,11 +291,13 @@ def test_search_when_end_date_is_before_begin_date_causes_exit(runner, cli_state def test_search_with_only_begin_calls_extract_with_expected_args( runner, cli_state, file_event_extractor, stdout_logger, begin_option ): - result = runner.invoke(cli, ["security-data", "search", "--begin", "1h"], obj=cli_state) + result = runner.invoke( + cli, ["security-data", "search", "--begin", "1h"], obj=cli_state + ) assert result.exit_code == 0 assert str( file_event_extractor.extract.call_args[0][1] - ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"eventTimestamp", ' '"value":"{}"}}]}}'.format( + ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"{}"}}]}}'.format( begin_option.expected_timestamp ) @@ -283,11 +324,15 @@ def test_search_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_ stdout_logger, ): result = runner.invoke( - cli, ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + cli, + ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], + obj=cli_state, ) assert result.exit_code == 0 assert len(file_event_extractor.extract.call_args[0]) == 2 - assert begin_option.expected_timestamp in str(file_event_extractor.extract.call_args[0][1]) + assert begin_option.expected_timestamp in str( + file_event_extractor.extract.call_args[0][1] + ) def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( @@ -298,25 +343,33 @@ def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_ca stdout_logger, ): result = runner.invoke( - cli, ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state + cli, + ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], + obj=cli_state, ) assert result.exit_code == 0 assert len(file_event_extractor.extract.call_args[0]) == 1 assert ( - "checkpoint of {} exists".format(file_event_cursor_with_checkpoint.expected_timestamp) + "checkpoint of {} exists".format( + file_event_cursor_with_checkpoint.expected_timestamp + ) in result.output ) def test_search_when_given_invalid_exposure_type_causes_exit(runner, cli_state): result = runner.invoke( - cli, ["security-data", "search", "--begin", "1d", "-t", "NotValid"], obj=cli_state + cli, + ["security-data", "search", "--begin", "1d", "-t", "NotValid"], + obj=cli_state, ) assert result.exit_code == 2 assert "invalid choice: NotValid" in result.output -def test_search_when_given_username_uses_username_filter(runner, cli_state, file_event_extractor): +def test_search_when_given_username_uses_username_filter( + runner, cli_state, file_event_extractor +): c42_username = "test@code42.com" runner.invoke( cli, @@ -324,16 +377,20 @@ def test_search_when_given_username_uses_username_filter(runner, cli_state, file obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(DeviceUsername.is_in([c42_username])) in filter_strings + assert str(f.DeviceUsername.is_in([c42_username])) in filter_strings -def test_search_when_given_actor_is_uses_username_filter(runner, cli_state, file_event_extractor): +def test_search_when_given_actor_is_uses_username_filter( + runner, cli_state, file_event_extractor +): actor_name = "test.testerson" runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--actor", actor_name], obj=cli_state + cli, + ["security-data", "search", "--begin", "1h", "--actor", actor_name], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(Actor.is_in([actor_name])) in filter_strings + assert str(f.Actor.is_in([actor_name])) in filter_strings def test_search_when_given_md5_uses_md5_filter(runner, cli_state, file_event_extractor): @@ -342,43 +399,59 @@ def test_search_when_given_md5_uses_md5_filter(runner, cli_state, file_event_ext cli, ["security-data", "search", "--begin", "1h", "--md5", md5], obj=cli_state ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(MD5.is_in([md5])) in filter_strings + assert str(f.MD5.is_in([md5])) in filter_strings -def test_search_when_given_sha256_uses_sha256_filter(runner, cli_state, file_event_extractor): +def test_search_when_given_sha256_uses_sha256_filter( + runner, cli_state, file_event_extractor +): sha_256 = "abcd12345" runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--sha256", sha_256], obj=cli_state + cli, + ["security-data", "search", "--begin", "1h", "--sha256", sha_256], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(SHA256.is_in([sha_256])) in filter_strings + assert str(f.SHA256.is_in([sha_256])) in filter_strings -def test_search_when_given_source_uses_source_filter(runner, cli_state, file_event_extractor): +def test_search_when_given_source_uses_source_filter( + runner, cli_state, file_event_extractor +): source = "Gmail" runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--source", source], obj=cli_state + cli, + ["security-data", "search", "--begin", "1h", "--source", source], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(Source.is_in([source])) in filter_strings + assert str(f.Source.is_in([source])) in filter_strings -def test_search_when_given_file_name_uses_file_name_filter(runner, cli_state, file_event_extractor): +def test_search_when_given_file_name_uses_file_name_filter( + runner, cli_state, file_event_extractor +): filename = "test.txt" runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--file-name", filename], obj=cli_state + cli, + ["security-data", "search", "--begin", "1h", "--file-name", filename], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(FileName.is_in([filename])) in filter_strings + assert str(f.FileName.is_in([filename])) in filter_strings -def test_search_when_given_file_path_uses_file_path_filter(runner, cli_state, file_event_extractor): +def test_search_when_given_file_path_uses_file_path_filter( + runner, cli_state, file_event_extractor +): filepath = "C:\\Program Files" runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--file-path", filepath], obj=cli_state + cli, + ["security-data", "search", "--begin", "1h", "--file-path", filepath], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(FilePath.is_in([filepath])) in filter_strings + assert str(f.FilePath.is_in([filepath])) in filter_strings def test_when_given_process_owner_uses_process_owner_filter( @@ -391,16 +464,20 @@ def test_when_given_process_owner_uses_process_owner_filter( obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(ProcessOwner.is_in([process_owner])) in filter_strings + assert str(f.ProcessOwner.is_in([process_owner])) in filter_strings -def test_when_given_tab_url_uses_process_tab_url_filter(runner, cli_state, file_event_extractor): +def test_when_given_tab_url_uses_process_tab_url_filter( + runner, cli_state, file_event_extractor +): tab_url = "https://example.com" runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--tab-url", tab_url], obj=cli_state, + cli, + ["security-data", "search", "--begin", "1h", "--tab-url", tab_url], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(TabURL.is_in([tab_url])) in filter_strings + assert str(f.TabURL.is_in([tab_url])) in filter_strings def test_when_given_exposure_types_uses_exposure_type_is_in_filter( @@ -408,28 +485,34 @@ def test_when_given_exposure_types_uses_exposure_type_is_in_filter( ): exposure_type = "SharedViaLink" runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--type", exposure_type], obj=cli_state, + cli, + ["security-data", "search", "--begin", "1h", "--type", exposure_type], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(ExposureType.is_in([exposure_type])) in filter_strings + assert str(f.ExposureType.is_in([exposure_type])) in filter_strings def test_when_given_include_non_exposure_does_not_include_exposure_type_exists( runner, cli_state, file_event_extractor ): runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--include-non-exposure"], obj=cli_state, + cli, + ["security-data", "search", "--begin", "1h", "--include-non-exposure"], + obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(ExposureType.exists()) not in filter_strings + assert str(f.ExposureType.exists()) not in filter_strings def test_when_not_given_include_non_exposure_includes_exposure_type_exists( runner, cli_state, file_event_extractor ): - runner.invoke(cli, ["security-data", "search", "--begin", "1h"], obj=cli_state,) + runner.invoke( + cli, ["security-data", "search", "--begin", "1h"], obj=cli_state, + ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(ExposureType.exists()) in filter_strings + assert str(f.ExposureType.exists()) in filter_strings def test_when_given_multiple_search_args_uses_expected_filters( @@ -455,9 +538,9 @@ def test_when_given_multiple_search_args_uses_expected_filters( obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(ProcessOwner.is_in([process_owner])) in filter_strings - assert str(FileName.is_in([filename])) in filter_strings - assert str(DeviceUsername.is_in([c42_username])) in filter_strings + assert str(f.ProcessOwner.is_in([process_owner])) in filter_strings + assert str(f.FileName.is_in([filename])) in filter_strings + assert str(f.DeviceUsername.is_in([c42_username])) in filter_strings def test_when_given_include_non_exposure_and_exposure_types_causes_exit( @@ -490,7 +573,9 @@ def file_search_error(x): cli_state.sdk.securitydata.search_file_events.side_effect = file_search_error with caplog.at_level(logging.ERROR): - result = runner.invoke(cli, ["security-data", "search", "--begin", "1d"], obj=cli_state) + result = runner.invoke( + cli, ["security-data", "search", "--begin", "1d"], obj=cli_state + ) assert exception_msg in result.output assert exception_msg in caplog.text assert errors.ERRORED @@ -545,5 +630,7 @@ def test_saved_search_list_calls_get_method(runner, cli_state): def test_show_detail_calls_get_by_id_method(runner, cli_state): test_id = "test_id" - runner.invoke(cli, ["security-data", "saved-search", "show", test_id], obj=cli_state) + runner.invoke( + cli, ["security-data", "saved-search", "show", test_id], obj=cli_state + ) cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with(test_id) diff --git a/tests/conftest.py b/tests/conftest.py index c410d577e..cbddf8a90 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ -from datetime import datetime, timedelta +from datetime import datetime +from datetime import timedelta import pytest from click.testing import CliRunner @@ -122,7 +123,7 @@ def cli_state(mocker, sdk, profile): return mock_state -class MockSection(object): +class MockSection: def __init__(self, name=None, values_dict=None): self.name = name self.values_dict = values_dict or create_profile_values_dict() @@ -171,7 +172,9 @@ def mock_listdir(mocker): return mocker.patch("os.listdir") -def func_keyword_args(one=None, two=None, three=None, default="testdefault", nargstest=[]): +def func_keyword_args( + one=None, two=None, three=None, default="testdefault", nargstest=None +): pass @@ -206,7 +209,7 @@ def func_with_args(args): def convert_str_to_date(date_str): - return datetime.strptime(date_str, u"%Y-%m-%dT%H:%M:%S.%fZ") + return datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ") def get_test_date(days_ago=None, hours_ago=None, minutes_ago=None): @@ -225,9 +228,9 @@ def get_test_date_str(days_ago): begin_date_str = get_test_date_str(days_ago=89) -begin_date_str_with_time = "{0} 3:12:33".format(begin_date_str) +begin_date_str_with_time = "{} 3:12:33".format(begin_date_str) end_date_str = get_test_date_str(days_ago=10) -end_date_str_with_time = "{0} 11:22:43".format(end_date_str) +end_date_str_with_time = "{} 11:22:43".format(end_date_str) begin_date_str = get_test_date_str(days_ago=89) begin_date_with_time = [get_test_date_str(days_ago=89), "3:12:33"] end_date_str = get_test_date_str(days_ago=10) diff --git a/tests/test_bulk.py b/tests/test_bulk.py index a912bf59c..2dd4eb49d 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -2,9 +2,10 @@ import pytest -from code42cli import PRODUCT_NAME from code42cli import errors -from code42cli.bulk import BulkProcessor, run_bulk_process +from code42cli import PRODUCT_NAME +from code42cli.bulk import BulkProcessor +from code42cli.bulk import run_bulk_process from code42cli.logger import get_view_error_details_message _NAMESPACE = "{}.bulk".format(PRODUCT_NAME) @@ -43,7 +44,7 @@ def test_run_bulk_process_creates_processor(bulk_processor_factory): bulk_processor_factory.assert_called_once_with(func_with_one_arg, rows, None) -class TestBulkProcessor(object): +class TestBulkProcessor: def test_run_when_reader_returns_ordered_dict_process_kwargs(self): processed_rows = [] @@ -67,7 +68,11 @@ def test_run_when_reader_returns_dict_process_kwargs(self): def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - rows = [{"test1": 1, "test2": 2}, {"test1": 3, "test2": 4}, {"test1": 5, "test2": 6}] + rows = [ + {"test1": 1, "test2": 2}, + {"test1": 3, "test2": 4}, + {"test1": 5, "test2": 6}, + ] processor = BulkProcessor(func_for_bulk, rows) processor.run() assert (1, 2) in processed_rows @@ -135,13 +140,15 @@ def func_for_bulk(test): assert "row2" in processed_rows assert "row3" not in processed_rows - def test_run_when_reader_returns_dict_rows_containing_empty_strs_converts_them_to_none(self): + def test_run_when_reader_returns_dict_rows_containing_empty_strs_converts_them_to_none( + self, + ): processed_rows = [] def func_for_bulk(test1, test2): processed_rows.append((test1, test2)) - rows = [{"test1": "", "test2": "foo"}, {"test1": "bar", "test2": u""}] + rows = [{"test1": "", "test2": "foo"}, {"test1": "bar", "test2": ""}] processor = BulkProcessor(func_for_bulk, rows) processor.run() assert (None, "foo") in processed_rows diff --git a/tests/test_config.py b/tests/test_config.py index 4bd9e4e80..6db938c97 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,8 +2,9 @@ import pytest -from code42cli.config import ConfigAccessor, NoConfigProfileError from .conftest import MockSection +from code42cli.config import ConfigAccessor +from code42cli.config import NoConfigProfileError _TEST_PROFILE_NAME = "ProfileA" _TEST_SECOND_PROFILE_NAME = "ProfileB" @@ -28,7 +29,9 @@ def config_parser_for_multiple_profiles(mock_config_parser): _TEST_SECOND_PROFILE_NAME, ] mock_profile_a = create_mock_profile_object(_TEST_PROFILE_NAME, "test", "test") - mock_profile_b = create_mock_profile_object(_TEST_SECOND_PROFILE_NAME, "test", "test") + mock_profile_b = create_mock_profile_object( + _TEST_SECOND_PROFILE_NAME, "test", "test" + ) mock_internal = create_internal_object(True, _TEST_PROFILE_NAME) @@ -86,7 +89,7 @@ def side_effect(item): parser.__getitem__.side_effect = side_effect -class TestConfigAccessor(object): +class TestConfigAccessor: def test_get_profile_when_profile_does_not_exist_raises(self, mock_config_parser): mock_config_parser.sections.return_value = [_INTERNAL] accessor = ConfigAccessor(mock_config_parser) @@ -115,7 +118,7 @@ def test_get_all_profiles_excludes_internal_section(self, mock_config_parser): profiles = accessor.get_all_profiles() for p in profiles: if p.name == _INTERNAL: - assert False + raise AssertionError() def test_get_all_profiles_returns_profiles_with_expected_values( self, config_parser_for_multiple_profiles @@ -131,16 +134,22 @@ def test_switch_default_profile_switches_internal_value( accessor = ConfigAccessor(config_parser_for_multiple_profiles) accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) assert ( - config_parser_for_multiple_profiles[_INTERNAL][ConfigAccessor.DEFAULT_PROFILE] + config_parser_for_multiple_profiles[_INTERNAL][ + ConfigAccessor.DEFAULT_PROFILE + ] == _TEST_SECOND_PROFILE_NAME ) - def test_switch_default_profile_saves(self, config_parser_for_multiple_profiles, mock_saver): + def test_switch_default_profile_saves( + self, config_parser_for_multiple_profiles, mock_saver + ): accessor = ConfigAccessor(config_parser_for_multiple_profiles) accessor.switch_default_profile(_TEST_SECOND_PROFILE_NAME) assert mock_saver.call_count - def test_create_profile_when_given_default_name_does_not_create(self, config_parser_for_create): + def test_create_profile_when_given_default_name_does_not_create( + self, config_parser_for_create + ): accessor = ConfigAccessor(config_parser_for_create) with pytest.raises(Exception): accessor.create_profile(ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False) @@ -169,7 +178,9 @@ def test_create_profile_when_has_default_profile_does_not_set_default( accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) assert not accessor.switch_default_profile.call_count - def test_create_profile_when_not_existing_saves(self, config_parser_for_create, mock_saver): + def test_create_profile_when_not_existing_saves( + self, config_parser_for_create, mock_saver + ): create_mock_profile_object(_TEST_PROFILE_NAME, None, None) mock_internal = create_internal_object(False) setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) @@ -191,9 +202,17 @@ def test_update_profile_updates_profile(self, config_parser_for_multiple_profile username = "NEW USERNAME" accessor.update_profile(_TEST_PROFILE_NAME, address, username, True) - assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.AUTHORITY_KEY] == address - assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.USERNAME_KEY] == username - assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] + assert ( + accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.AUTHORITY_KEY] + == address + ) + assert ( + accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.USERNAME_KEY] + == username + ) + assert accessor.get_profile(_TEST_PROFILE_NAME)[ + ConfigAccessor.IGNORE_SSL_ERRORS_KEY + ] def test_update_profile_does_not_update_when_given_none( self, config_parser_for_multiple_profiles @@ -206,6 +225,14 @@ def test_update_profile_does_not_update_when_given_none( accessor.update_profile(_TEST_PROFILE_NAME, address, username, True) accessor.update_profile(_TEST_PROFILE_NAME, None, None, None) - assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.AUTHORITY_KEY] == address - assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.USERNAME_KEY] == username - assert accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] + assert ( + accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.AUTHORITY_KEY] + == address + ) + assert ( + accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.USERNAME_KEY] + == username + ) + assert accessor.get_profile(_TEST_PROFILE_NAME)[ + ConfigAccessor.IGNORE_SSL_ERRORS_KEY + ] diff --git a/tests/test_cursor_store.py b/tests/test_cursor_store.py index 521f0addf..e7de86f91 100644 --- a/tests/test_cursor_store.py +++ b/tests/test_cursor_store.py @@ -1,11 +1,12 @@ from os import path -from io import IOBase, StringIO import pytest from code42cli import PRODUCT_NAME +from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.cmds.search.cursor_store import Cursor +from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.errors import Code42CLIError -from code42cli.cmds.search.cursor_store import Cursor, AlertCursorStore, FileEventCursorStore PROFILE_NAME = "testprofile" CURSOR_NAME = "testcursor" @@ -26,7 +27,7 @@ def mock_isfile(mocker): return mock -class TestCursor(object): +class TestCursor: def test_name_returns_expected_name(self): cursor = Cursor("bogus/path") assert cursor.name == "path" @@ -41,7 +42,7 @@ def test_value_reads_expected_file(self, mock_open): mock_open.assert_called_once_with("bogus/path") -class TestAlertCursorStore(object): +class TestAlertCursorStore: def test_get_returns_expected_timestamp(self, mock_open): store = AlertCursorStore(PROFILE_NAME) checkpoint = store.get(CURSOR_NAME) @@ -58,14 +59,18 @@ def test_get_reads_expected_file(self, mock_open): store = AlertCursorStore(PROFILE_NAME) store.get(CURSOR_NAME) user_path = path.join(path.expanduser("~"), ".code42cli") - expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, CURSOR_NAME) + expected_path = path.join( + user_path, "alert_checkpoints", PROFILE_NAME, CURSOR_NAME + ) mock_open.assert_called_once_with(expected_path) def test_replace_writes_to_expected_file(self, mock_open): store = AlertCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) user_path = path.join(path.expanduser("~"), ".code42cli") - expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname") + expected_path = path.join( + user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname" + ) mock_open.assert_called_once_with(expected_path, "w") def test_replace_writes_expected_content(self, mock_open): @@ -79,10 +84,14 @@ def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): store = AlertCursorStore(PROFILE_NAME) store.delete("deleteme") user_path = path.join(path.expanduser("~"), ".code42cli") - expected_path = path.join(user_path, "alert_checkpoints", PROFILE_NAME, "deleteme") + expected_path = path.join( + user_path, "alert_checkpoints", PROFILE_NAME, "deleteme" + ) mock_remove.assert_called_once_with(expected_path) - def test_delete_when_checkpoint_does_not_exist_raises_cli_error(self, mock_open, mock_remove): + def test_delete_when_checkpoint_does_not_exist_raises_cli_error( + self, mock_open, mock_remove + ): store = AlertCursorStore(PROFILE_NAME) mock_remove.side_effect = FileNotFoundError with pytest.raises(Code42CLIError): @@ -96,7 +105,9 @@ def test_clean_calls_remove_on_each_checkpoint( store.clean() assert mock_remove.call_count == 3 - def test_get_all_cursors_returns_all_checkpoints(self, mock_open, mock_listdir, mock_isfile): + def test_get_all_cursors_returns_all_checkpoints( + self, mock_open, mock_listdir, mock_isfile + ): mock_listdir.return_value = ["fileone", "filetwo", "filethree"] store = AlertCursorStore(PROFILE_NAME) cursors = store.get_all_cursors() @@ -106,7 +117,7 @@ def test_get_all_cursors_returns_all_checkpoints(self, mock_open, mock_listdir, assert cursors[2].name == "filethree" -class TestFileEventCursorStore(object): +class TestFileEventCursorStore: def test_get_returns_expected_timestamp(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) checkpoint = store.get(CURSOR_NAME) @@ -116,7 +127,9 @@ def test_get_reads_expected_file(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) store.get(CURSOR_NAME) user_path = path.join(path.expanduser("~"), ".code42cli") - expected_path = path.join(user_path, "file_event_checkpoints", PROFILE_NAME, CURSOR_NAME) + expected_path = path.join( + user_path, "file_event_checkpoints", PROFILE_NAME, CURSOR_NAME + ) mock_open.assert_called_once_with(expected_path) def test_get_when_profile_does_not_exist_returns_none(self, mocker): @@ -124,7 +137,7 @@ def test_get_when_profile_does_not_exist_returns_none(self, mocker): checkpoint = store.get(CURSOR_NAME) mock_open = mocker.patch("{}.open".format(_NAMESPACE)) mock_open.side_effect = FileNotFoundError - assert checkpoint == None + assert checkpoint is None def test_replace_writes_to_expected_file(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) @@ -139,19 +152,21 @@ def test_replace_writes_expected_content(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) user_path = path.join(path.expanduser("~"), ".code42cli") - path.join( - user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname" - ) + path.join(user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname") mock_open.return_value.write.assert_called_once_with("123") def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): store = FileEventCursorStore(PROFILE_NAME) store.delete("deleteme") user_path = path.join(path.expanduser("~"), ".code42cli") - expected_path = path.join(user_path, "file_event_checkpoints", PROFILE_NAME, "deleteme") + expected_path = path.join( + user_path, "file_event_checkpoints", PROFILE_NAME, "deleteme" + ) mock_remove.assert_called_once_with(expected_path) - def test_delete_when_checkpoint_does_not_exist_raises_cli_error(self, mock_open, mock_remove): + def test_delete_when_checkpoint_does_not_exist_raises_cli_error( + self, mock_open, mock_remove + ): store = FileEventCursorStore(PROFILE_NAME) mock_remove.side_effect = FileNotFoundError with pytest.raises(Code42CLIError): diff --git a/tests/test_date_helper.py b/tests/test_date_helper.py index ecbe2b8c3..7846b30c6 100644 --- a/tests/test_date_helper.py +++ b/tests/test_date_helper.py @@ -2,19 +2,20 @@ from c42eventextractor.common import convert_datetime_to_timestamp -from code42cli.date_helper import parse_min_timestamp, parse_max_timestamp -from .conftest import ( - begin_date_str, - begin_date_str_with_time, - end_date_str, - end_date_str_with_time, - get_test_date, -) +from .conftest import begin_date_str +from .conftest import begin_date_str_with_time +from .conftest import end_date_str +from .conftest import end_date_str_with_time +from .conftest import get_test_date +from code42cli.date_helper import parse_max_timestamp +from code42cli.date_helper import parse_min_timestamp def test_parse_min_timestamp_when_given_date_str_parses_successfully(): actual = parse_min_timestamp(begin_date_str) - expected = convert_datetime_to_timestamp(datetime.strptime(begin_date_str, "%Y-%m-%d")) + expected = convert_datetime_to_timestamp( + datetime.strptime(begin_date_str, "%Y-%m-%d") + ) assert actual == expected @@ -49,7 +50,9 @@ def test_parse_min_timestamp_when_given_magic_minutes_parses_successfully(): def test_parse_max_timestamp_when_given_date_str_parses_successfully(): actual = parse_min_timestamp(end_date_str) - expected = convert_datetime_to_timestamp(datetime.strptime(end_date_str, "%Y-%m-%d")) + expected = convert_datetime_to_timestamp( + datetime.strptime(end_date_str, "%Y-%m-%d") + ) assert actual == expected @@ -66,7 +69,9 @@ def test_parse_max_timestamp_when_given_magic_days_parses_successfully(): expected_date = datetime.utcfromtimestamp( convert_datetime_to_timestamp(get_test_date(days_ago=20)) ) - expected_date = expected_date.replace(hour=23, minute=59, second=59, microsecond=999000) + expected_date = expected_date.replace( + hour=23, minute=59, second=59, microsecond=999000 + ) assert actual_date == expected_date diff --git a/tests/test_logger.py b/tests/test_logger.py index 3e69281fd..29c32d6b7 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -3,12 +3,11 @@ from logging.handlers import RotatingFileHandler from requests import Request -from code42cli.logger import ( - add_handler_to_logger, - logger_has_handlers, - get_view_error_details_message, - CliLogger, -) + +from code42cli.logger import add_handler_to_logger +from code42cli.logger import CliLogger +from code42cli.logger import get_view_error_details_message +from code42cli.logger import logger_has_handlers from code42cli.util import get_user_project_path @@ -37,11 +36,11 @@ def test_logger_has_handlers_when_logger_does_not_have_handlers_returns_false(): def test_get_view_exceptions_location_message_returns_expected_message(): actual = get_view_error_details_message() path = os.path.join(get_user_project_path("log"), "code42_errors.log") - expected = u"View details in {}".format(path) + expected = "View details in {}".format(path) assert actual == expected -class TestCliLogger(object): +class TestCliLogger: _logger = CliLogger() @@ -56,7 +55,9 @@ def test_log_error_logs_expected_text_at_expected_level(self, caplog): self._logger.log_error(ex) assert str(ex) in caplog.text - def test_log_verbose_error_logs_expected_text_at_expected_level(self, mocker, caplog): + def test_log_verbose_error_logs_expected_text_at_expected_level( + self, mocker, caplog + ): with caplog.at_level(logging.ERROR): request = mocker.MagicMock(sepc=Request) request.body = {"foo": "bar"} diff --git a/tests/test_logger_factory.py b/tests/test_logger_factory.py index 62c467dfb..6b51d8634 100644 --- a/tests/test_logger_factory.py +++ b/tests/test_logger_factory.py @@ -1,11 +1,8 @@ import logging -import pytest -from c42eventextractor.logging.formatters import ( - FileEventDictToCEFFormatter, - FileEventDictToJSONFormatter, - FileEventDictToRawJSONFormatter, -) +from c42eventextractor.logging.formatters import FileEventDictToCEFFormatter +from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter +from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter import code42cli.cmds.search.logger_factory as factory diff --git a/tests/test_password.py b/tests/test_password.py index 5c5fc0fcc..22808bfcd 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -73,7 +73,11 @@ def test_set_password_uses_expected_service_name_username_and_password( def test_set_password_when_using_file_fallback_and_user_accepts_saves_password( - profile, keyring_password_setter, keyring_password_getter, get_keyring, user_agreement + profile, + keyring_password_setter, + keyring_password_getter, + get_keyring, + user_agreement, ): keyring_password_getter.return_value = "test_password" profile.name = "profile_name" diff --git a/tests/test_profile.py b/tests/test_profile.py index 357187c63..023cd4160 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,10 +1,13 @@ import pytest import code42cli.profile as cliprofile +from .conftest import create_mock_profile +from .conftest import MockSection from code42cli import PRODUCT_NAME -from code42cli.cmds.search.cursor_store import FileEventCursorStore, AlertCursorStore -from code42cli.config import ConfigAccessor, NoConfigProfileError -from .conftest import MockSection, create_mock_profile +from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.cmds.search.cursor_store import FileEventCursorStore +from code42cli.config import ConfigAccessor +from code42cli.config import NoConfigProfileError from code42cli.errors import Code42CLIError @@ -30,15 +33,21 @@ def password_deleter(mocker): return mocker.patch("code42cli.password.delete_password") -class TestCode42Profile(object): - def test_get_password_when_is_none_returns_password_from_getpass(self, mocker, password_getter): +class TestCode42Profile: + def test_get_password_when_is_none_returns_password_from_getpass( + self, mocker, password_getter + ): password_getter.return_value = None - mock_getpass = mocker.patch("{}.password.get_password_from_prompt".format(PRODUCT_NAME)) + mock_getpass = mocker.patch( + "{}.password.get_password_from_prompt".format(PRODUCT_NAME) + ) mock_getpass.return_value = "Test Password" actual = create_mock_profile().get_password() assert actual == "Test Password" - def test_get_password_return_password_from_password_get_password(self, password_getter): + def test_get_password_return_password_from_password_get_password( + self, password_getter + ): password_getter.return_value = "Test Password" actual = create_mock_profile().get_password() assert actual == "Test Password" @@ -57,7 +66,7 @@ def test_username_returns_expected_value(self): def test_ignore_ssl_errors_returns_expected_value(self): mock_profile = create_mock_profile() - assert mock_profile.ignore_ssl_errors == True + assert mock_profile.ignore_ssl_errors def test_get_profile_returns_expected_profile(config_accessor): @@ -137,7 +146,9 @@ def test_create_profile_uses_expected_profile_values(config_accessor): ) -def test_create_profile_if_profile_exists_exits(mocker, cli_state, caplog, config_accessor): +def test_create_profile_if_profile_exists_exits( + mocker, cli_state, caplog, config_accessor +): config_accessor.get_profile.return_value = mocker.MagicMock() with pytest.raises(Code42CLIError): cliprofile.create_profile("foo", "bar", "baz", True) @@ -154,15 +165,18 @@ def test_get_all_profiles_returns_expected_profile_list(config_accessor): assert profiles[1].name == "two" -def test_get_stored_password_returns_expected_password(config_accessor, password_getter): +def test_get_stored_password_returns_expected_password( + config_accessor, password_getter +): mock_section = MockSection("testprofilename") config_accessor.get_profile.return_value = mock_section - test_profile = "testprofilename" password_getter.return_value = "testpassword" assert cliprofile.get_stored_password("testprofilename") == "testpassword" -def test_get_stored_password_uses_expected_profile_name(config_accessor, password_getter): +def test_get_stored_password_uses_expected_profile_name( + config_accessor, password_getter +): mock_section = MockSection("testprofilename") config_accessor.get_profile.return_value = mock_section test_profile = "testprofilename" @@ -204,7 +218,9 @@ def test_delete_profile_clears_checkpoints(config_accessor, mocker): mock_get_profile.return_value = profile event_store = mocker.MagicMock(spec=FileEventCursorStore) alert_store = mocker.MagicMock(spec=AlertCursorStore) - mock_get_cursor_store = mocker.patch("code42cli.profile.get_all_cursor_stores_for_profile") + mock_get_cursor_store = mocker.patch( + "code42cli.profile.get_all_cursor_stores_for_profile" + ) mock_get_cursor_store.return_value = [event_store, alert_store] cliprofile.delete_profile("deleteme") assert event_store.clean.call_count == 1 diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 6532bb15a..470ae6b56 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -3,11 +3,14 @@ import pytest from py42.exceptions import Py42UnauthorizedError from requests import Response -from requests.exceptions import ConnectionError, RequestException +from requests.exceptions import ConnectionError +from requests.exceptions import RequestException -from code42cli.errors import Code42CLIError, LoggedCLIError -from code42cli.sdk_client import create_sdk, validate_connection from .conftest import create_mock_profile +from code42cli.errors import Code42CLIError +from code42cli.errors import LoggedCLIError +from code42cli.sdk_client import create_sdk +from code42cli.sdk_client import validate_connection @pytest.fixture @@ -35,9 +38,9 @@ def test_create_sdk_when_profile_has_ssl_errors_disabled_sets_py42_setting_and_p profile.ignore_ssl_errors = "True" create_sdk(profile, False) output = capsys.readouterr() - assert mock_py42.settings.verify_ssl_certs == False + assert not mock_py42.settings.verify_ssl_certs assert ( - "Warning: Profile '{0}' has SSL verification disabled. Adding certificate verification is strongly advised.".format( + "Warning: Profile '{}' has SSL verification disabled. Adding certificate verification is strongly advised.".format( profile.name ) in output.err diff --git a/tests/test_util.py b/tests/test_util.py index 7e2e4b5a3..98901a440 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,14 +1,12 @@ import pytest from code42cli import PRODUCT_NAME -from code42cli.util import ( - does_user_agree, - find_format_width, - format_string_list_to_columns, - _PADDING_SIZE, -) +from code42cli.util import _PADDING_SIZE +from code42cli.util import does_user_agree +from code42cli.util import find_format_width +from code42cli.util import format_string_list_to_columns -TEST_HEADER = {u"key1": u"Column 1", u"key2": u"Column 10", u"key3": u"Column 100"} +TEST_HEADER = {"key1": "Column 1", "key2": "Column 10", "key3": "Column 100"} @pytest.fixture @@ -41,17 +39,23 @@ def get_expected_row_width(max_col_len, max_width): return col_size * num_cols -def test_does_user_agree_when_user_says_y_returns_true(mocker, context_without_assume_yes): +def test_does_user_agree_when_user_says_y_returns_true( + mocker, context_without_assume_yes +): mocker.patch("builtins.input", return_value="y") assert does_user_agree("Test Prompt") -def test_does_user_agree_when_user_says_capital_y_returns_true(mocker, context_without_assume_yes): +def test_does_user_agree_when_user_says_capital_y_returns_true( + mocker, context_without_assume_yes +): mocker.patch("builtins.input", return_value="Y") assert does_user_agree("Test Prompt") -def test_does_user_agree_when_user_says_n_returns_false(mocker, context_without_assume_yes): +def test_does_user_agree_when_user_says_n_returns_false( + mocker, context_without_assume_yes +): mocker.patch("builtins.input", return_value="n") assert not does_user_agree("Test Prompt") @@ -67,40 +71,44 @@ def test_does_user_agree_when_assume_yes_argument_passed_returns_true_and_does_n def test_find_format_width_when_zero_records_sets_width_to_header_length(): _, column_width = find_format_width([], TEST_HEADER) - assert column_width[u"key1"] == len(TEST_HEADER[u"key1"]) - assert column_width[u"key2"] == len(TEST_HEADER[u"key2"]) - assert column_width[u"key3"] == len(TEST_HEADER[u"key3"]) + assert column_width["key1"] == len(TEST_HEADER["key1"]) + assert column_width["key2"] == len(TEST_HEADER["key2"]) + assert column_width["key3"] == len(TEST_HEADER["key3"]) def test_find_format_width_when_records_sets_width_to_greater_of_data_or_header_length(): report = [ - {u"key1": u"test 1", u"key2": u"value xyz test", u"key3": u"test test test test"}, - {u"key1": u"1", u"key2": u"value xyz", u"key3": u"test test test test"}, + {"key1": "test 1", "key2": "value xyz test", "key3": "test test test test"}, + {"key1": "1", "key2": "value xyz", "key3": "test test test test"}, ] _, column_width = find_format_width(report, TEST_HEADER) - assert column_width[u"key1"] == len(TEST_HEADER[u"key1"]) - assert column_width[u"key2"] == len(report[0][u"key2"]) - assert column_width[u"key3"] == len(report[1][u"key3"]) + assert column_width["key1"] == len(TEST_HEADER["key1"]) + assert column_width["key2"] == len(report[0]["key2"]) + assert column_width["key3"] == len(report[1]["key3"]) def test_find_format_width_filters_keys_not_present_in_header(): report = [ - {u"key1": u"test 1", u"key2": u"value xyz test", u"key3": u"test test test test"}, - {u"key1": u"1", u"key2": u"value xyz", u"key3": u"test test test test"}, + {"key1": "test 1", "key2": "value xyz test", "key3": "test test test test"}, + {"key1": "1", "key2": "value xyz", "key3": "test test test test"}, ] - header_with_subset_keys = {u"key1": u"Column 1", u"key3": u"Column 100"} + header_with_subset_keys = {"key1": "Column 1", "key3": "Column 100"} result, _ = find_format_width(report, header_with_subset_keys) for item in result: - assert u"key2" not in item.keys() + assert "key2" not in item.keys() -def test_format_string_list_to_columns_when_given_no_string_list_does_not_echo(echo_output): +def test_format_string_list_to_columns_when_given_no_string_list_does_not_echo( + echo_output, +): format_string_list_to_columns([], None) format_string_list_to_columns(None, None) assert not echo_output.call_count -def test_format_string_list_to_columns_when_not_given_max_uses_shell_size(mocker, echo_output): +def test_format_string_list_to_columns_when_not_given_max_uses_shell_size( + mocker, echo_output +): terminal_size = mocker.patch("code42cli.util.shutil.get_terminal_size") max_width = 30 terminal_size.return_value = (max_width, None) # Cols, Rows @@ -113,7 +121,9 @@ def test_format_string_list_to_columns_when_not_given_max_uses_shell_size(mocker assert printed_row == "col1 col2 " -def test_format_string_list_to_columns_when_given_small_max_width_prints_one_column_per_row(echo_output): +def test_format_string_list_to_columns_when_given_small_max_width_prints_one_column_per_row( + echo_output, +): max_width = 5 columns = ["col1", "col2"] @@ -135,7 +145,9 @@ def test_format_string_list_to_columns_uses_width_of_longest_string(echo_output) columns = ["col1", "col2_that_is_really_long"] format_string_list_to_columns(columns, max_width) - expected_row_width = get_expected_row_width(len("col2_that_is_really_long"), max_width) + expected_row_width = get_expected_row_width( + len("col2_that_is_really_long"), max_width + ) printed_row = echo_output.call_args_list[0][0][0] assert len(printed_row) == expected_row_width assert printed_row == "col1 " diff --git a/tests/test_worker.py b/tests/test_worker.py index 2666596d7..4efe5b9b8 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1,16 +1,17 @@ import time -from code42cli.worker import Worker, WorkerStats +from code42cli.worker import Worker +from code42cli.worker import WorkerStats -class TestWorkerStats(object): +class TestWorkerStats: def test_successes_when_should_be_negative_returns_zero(self): stats = WorkerStats(100) stats._total_errors = 101 assert not stats.total_successes -class TestWorker(object): +class TestWorker: def test_is_async(self): worker = Worker(5, 2) demo_ls = [] diff --git a/tox.ini b/tox.ini index 3e27d3bca..56b8d5f63 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,15 @@ [tox] -envlist = clean,py35,py36,py37,py38,report,lint37 - -# don't require all versions of python to be installed to run tests. -# the github workflow ensures that this is run with each necessary python version. +envlist = + py{38,37,36,35,27} + docs + style skip_missing_interpreters = true [testenv] -# install pytest in the virtualenv where commands will be executed. deps = - pytest == 4.6.5 + pytest == 4.6.11 pytest-mock == 2.0.0 - pytest-cov == 2.8.1 + pytest-cov == 2.10.0 commands = # -v: verbose @@ -18,25 +17,17 @@ commands = # -l: show locals in tracebacks # --tb=short: short traceback print mode # --strict: marks not registered in configuration file raise errors - pytest tests --cov=code42cli --cov-append -v -rsxX -l --tb=short --strict --disable-pytest-warnings + pytest --cov=code42cli --cov-report xml -v -rsxX -l --tb=short --strict -depends = - {py35,py36,py37,py38}: clean - report: py35,py36,py37,py38 +[testenv:docs] +deps = + sphinx + recommonmark + sphinx_rtd_theme -[testenv:report] -deps = coverage -skip_install = true -commands = - coverage report - coverage html +commands = sphinx-build -W -b html -d {envtmpdir}/doctress docs {envtmpdir}/html -[testenv:clean] -deps = coverage +[testenv:style] +deps = pre-commit skip_install = true -commands = coverage erase - -[testenv:lint37] -basepython = python3.7 -deps = pylint==2.4.0 -commands = pylint -E code42cli +commands = pre-commit run --all-files --show-diff-on-failure From 15f0378baeaf7ecbaef03def59b035ce910dc210 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 23 Jul 2020 11:56:43 -0500 Subject: [PATCH 095/349] Feature/Option to use OR queries on security-data/alerts search (#117) * add --password option to `profile create` and `profile update` commands * remove unused `_reset_pw()` * remove unused `_reset_pw()` * clarify reset-pw help * fix bad merge and test * add --or-query option to `alerts search` and `security-data search` * bump required extractor version * fixes * fix style * tests for OR expected query * style fix * remove unused result var * change or_query check * change or_query check on security-data --- setup.py | 4 +-- src/code42cli/cmds/alerts.py | 8 ++++- src/code42cli/cmds/profile.py | 12 ++----- src/code42cli/cmds/securitydata.py | 15 +++++++- tests/cmds/test_alerts.py | 56 +++++++++++++++++++++++++++++ tests/cmds/test_securitydata.py | 57 ++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 14 deletions(-) diff --git a/setup.py b/setup.py index be32cc3f9..3c9bb8f3a 100644 --- a/setup.py +++ b/setup.py @@ -33,10 +33,10 @@ install_requires=[ "click>=7.1.1", "colorama>=0.4.3", - "c42eventextractor==0.3.2", + "c42eventextractor==0.4.0", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42>=1.5.1", + "py42>=1.7.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 0a1a00259..0c23989c5 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -155,8 +155,13 @@ def clear_checkpoint(state, checkpoint_name): @alerts.command() @alert_options @search_options +@click.option( + "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible +) @opt.sdk_options -def search(cli_state, format, begin, end, advanced_query, use_checkpoint, **kwargs): +def search( + cli_state, format, begin, end, advanced_query, use_checkpoint, or_query, **kwargs +): """Search for alerts.""" output_logger = logger_factory.get_logger_for_stdout(format) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None @@ -164,6 +169,7 @@ def search(cli_state, format, begin, end, advanced_query, use_checkpoint, **kwar cli_state.sdk, AlertExtractor, output_logger, cursor, use_checkpoint ) extractor = _get_alert_extractor(cli_state.sdk, handlers) + extractor.use_or_query = or_query if advanced_query: extractor.extract_advanced(advanced_query) else: diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 1247f2b6f..d3567b8f7 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -26,19 +26,11 @@ def profile(): help="The name of the Code42 CLI profile to use when executing this command.", ) server_option = click.option( - "-s", - "--server", - required=True, - type=str, - help="The url and port of the Code42 server.", + "-s", "--server", required=True, help="The url and port of the Code42 server.", ) username_option = click.option( - "-u", - "--username", - required=True, - type=str, - help="The username of the Code42 API user.", + "-u", "--username", required=True, help="The username of the Code42 API user.", ) password_option = click.option( diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 2a03e8d18..c8f560084 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -163,9 +163,20 @@ def clear_checkpoint(state, checkpoint_name): @security_data.command() @file_event_options @search_options +@click.option( + "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible +) @sdk_options def search( - state, format, begin, end, advanced_query, use_checkpoint, saved_search, **kwargs + state, + format, + begin, + end, + advanced_query, + use_checkpoint, + saved_search, + or_query, + **kwargs ): """Search for file events.""" output_logger = logger_factory.get_logger_for_stdout(format) @@ -176,6 +187,8 @@ def search( state.sdk, FileEventExtractor, output_logger, cursor, use_checkpoint ) extractor = _get_file_event_extractor(state.sdk, handlers) + extractor.use_or_query = or_query + extractor.or_query_exempt_filters.append(f.ExposureType.exists()) if advanced_query: extractor.extract_advanced(advanced_query) elif saved_search: diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index a3758b7f3..c9a61a059 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -1,3 +1,5 @@ +import json + import py42.sdk.queries.alerts.filters as f import pytest from c42eventextractor.extractors import AlertExtractor @@ -576,3 +578,57 @@ def test_search_when_given_multiple_search_args_uses_expected_filters( assert str(f.Actor.is_in([actor])) in filter_strings assert str(f.Actor.not_in([exclude_actor])) in filter_strings assert str(f.RuleName.is_in([rule_name])) in filter_strings + + +def test_search_with_or_query_flag_produces_expected_query(runner, cli_state): + begin_date = get_test_date_str(days_ago=10) + test_actor = "test@example.com" + test_rule_type = "FedEndpointExfiltration" + runner.invoke( + cli, + [ + "alerts", + "search", + "--or-query", + "--begin", + begin_date, + "--actor", + test_actor, + "--rule-type", + test_rule_type, + ], + obj=cli_state, + ) + expected_query = { + "tenantId": None, + "groupClause": "AND", + "groups": [ + { + "filterClause": "AND", + "filters": [ + { + "operator": "ON_OR_AFTER", + "term": "createdAt", + "value": "{}T00:00:00.000Z".format(begin_date), + } + ], + }, + { + "filterClause": "OR", + "filters": [ + {"operator": "IS", "term": "actor", "value": "test@example.com"}, + { + "operator": "IS", + "term": "type", + "value": "FedEndpointExfiltration", + }, + ], + }, + ], + "pgNum": 0, + "pgSize": 500, + "srtDirection": "asc", + "srtKey": "CreatedAt", + } + actual_query = json.loads(str(cli_state.sdk.alerts.search.call_args[0][0])) + assert actual_query == expected_query diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 8a801738b..2c681745a 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -1,3 +1,4 @@ +import json import logging import py42.sdk.queries.fileevents.filters as f @@ -634,3 +635,59 @@ def test_show_detail_calls_get_by_id_method(runner, cli_state): cli, ["security-data", "saved-search", "show", test_id], obj=cli_state ) cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with(test_id) + + +def test_search_with_or_query_flag_produces_expected_query(runner, cli_state): + begin_date = get_test_date_str(days_ago=10) + test_username = "test@example.com" + test_filename = "test.txt" + runner.invoke( + cli, + [ + "security-data", + "search", + "--or-query", + "--begin", + begin_date, + "--c42-username", + test_username, + "--file-name", + test_filename, + ], + obj=cli_state, + ) + expected_query = { + "groupClause": "AND", + "groups": [ + { + "filterClause": "AND", + "filters": [ + {"operator": "EXISTS", "term": "exposure", "value": None}, + { + "operator": "ON_OR_AFTER", + "term": "eventTimestamp", + "value": "{}T00:00:00.000Z".format(begin_date), + }, + ], + }, + { + "filterClause": "OR", + "filters": [ + { + "operator": "IS", + "term": "deviceUserName", + "value": "test@example.com", + }, + {"operator": "IS", "term": "fileName", "value": "test.txt"}, + ], + }, + ], + "pgNum": 1, + "pgSize": 10000, + "srtDir": "asc", + "srtKey": "insertionTimestamp", + } + actual_query = json.loads( + str(cli_state.sdk.securitydata.search_file_events.call_args[0][0]) + ) + assert actual_query == expected_query From 382751236a0ca524ee38824ec39f40065cc8018f Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Fri, 24 Jul 2020 15:30:19 -0500 Subject: [PATCH 096/349] Update gettingstarted.md (#121) --- docs/userguides/gettingstarted.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index 7f0095b17..4dd3006de 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -1,4 +1,4 @@ -# Getting started with the code42cli +# Get started with the Code42 command-line interface (CLI) * [Licensing](#licensing) * [Installation](#installation) @@ -22,7 +22,7 @@ python3 -m pip install code42cli ``` To install a previous version of the Code42 CLI via `pip`, add the version number. For example, to install version -0.4.1, you would enter: +0.4.1, enter: ```bash python3 -m pip install code42cli==0.5.3 @@ -46,7 +46,7 @@ python setup.py install ### From distribution -If you want create a `.tar` ball for installing elsewhere, run this command from the project's root directory: +If you want create a `.tar` ball for installing elsewhere, run the following command from the project's root directory: ```bash python setup.py sdist @@ -60,7 +60,7 @@ python3 -m pip install code42cli-[VERSION].tar.gz ## Updates -To update the CLI, use pip's `--upgrade` flag. +To update the CLI, use the pip `--upgrade` flag. ```bash python3 -m pip install code42cli --upgrade @@ -73,12 +73,12 @@ python3 -m pip install code42cli --upgrade ``` To use the CLI, you must provide your credentials (basic authentication). The CLI uses keyring when storing passwords. -If you choose not to store your password in the CLI, you have to enter it for each command that requires a connection. +If you choose not to store your password in the CLI, you must enter it for each command that requires a connection. The Code42 CLI currently does **not** support SSO login providers or any other identity providers such as Active Directory or Okta. -To learn more about authenticating in the CLI, follow the [profile guide](profile.md). +To learn more about authenticating in the CLI, follow the [Configure profile guide](profile.md). ## Troubleshooting and support @@ -94,8 +94,8 @@ code42 -d ### File an issue on GitHub -If you are experiencing an issue with the Code42 CLI, you can create a *New issue* at the -[project repository](https://github.com/code42/code42cli/issues). See the Github +If you are experiencing an issue with the Code42 CLI, select *New issue* at the +[project repository](https://github.com/code42/code42cli/issues) to create an issue. See the Github [guide on creating an issue](https://help.github.com/en/github/managing-your-work-on-github/creating-an-issue) for more information. ### Contact Code42 Support From df79db2ed4a620a725c688f76cc54c539cadefd3 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Fri, 24 Jul 2020 15:30:38 -0500 Subject: [PATCH 097/349] Update index.md (#119) --- docs/index.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/index.md b/docs/index.md index 65e9968c2..93e62d4ab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -# Code42 console command-line interface reference +# Code42 command-line interface (CLI) [![license](https://img.shields.io/pypi/l/code42cli.svg)](https://pypi.org/project/code42cli/) [![versions](https://img.shields.io/pypi/pyversions/code42cli.svg)](https://pypi.org/project/code42cli/) @@ -6,14 +6,14 @@ The Code42 command-line interface (CLI) tool offers a way to interact with your Code42 environment without using the Code42 console or making API calls directly. For example, you can use it to extract Code42 data for use in a security information and event management (SIEM) tool or manage users on the High Risk Employees list or Departing Employees -list. This article provides instructions for installing, uninstalling, and upgrading the Code42 CLI. +list. ## Requirements To use the Code42 CLI, you must have: -A Code42 Diamond or Platinum product plan -Endpoint monitoring enabled in the Code42 console -Python version 3.5 and later installed +* A Code42 Diamond or Platinum product plan +* Endpoint monitoring enabled in the Code42 console +* Python version 3.5 and later installed ## Content From f58f7f56e75617e827bdeb34c9583df0503b0f30 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Mon, 27 Jul 2020 19:51:45 +0530 Subject: [PATCH 098/349] added validation for departure date (#120) --- src/code42cli/cmds/departing_employee.py | 6 ++++- tests/cmds/test_departing_employee.py | 30 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index a6f2faa1e..d0963e5d4 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -25,13 +25,17 @@ def departing_employee(state): @departing_employee.command() @username_arg @click.option( - "--departure-date", help="The date the employee is departing. Format: yyyy-MM-dd." + "--departure-date", + help="The date the employee is departing. Format: yyyy-MM-dd.", + type=click.DateTime(formats=["%Y-%m-%d"]), ) @cloud_alias_option @notes_option @sdk_options def add(state, username, cloud_alias, departure_date, notes): """Add a user to the departing-employee detection list.""" + if departure_date: + departure_date = departure_date.strftime("%Y-%m-%d") _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index b6b935b9a..2dc6593ce 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -157,3 +157,33 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state_wit obj=cli_state_with_user, ) assert bulk_processor.call_args[0][1] == ["test_user1", "test_user2"] + + +def test_add_departing_employee_when_invalid_date_validation_raises_error( + runner, cli_state_with_user +): + departure_date = "2020-02-30" + result = runner.invoke( + cli, + ["departing-employee", "add", _EMPLOYEE, "--departure-date", departure_date], + obj=cli_state_with_user, + ) + assert result.exit_code == 2 + assert ( + "Invalid value for '--departure-date': invalid datetime format" in result.output + ) + + +def test_add_departing_employee_when_invalid_date_format_validation_raises_error( + runner, cli_state_with_user +): + departure_date = "2020-30-01" + result = runner.invoke( + cli, + ["departing-employee", "add", _EMPLOYEE, "--departure-date", departure_date], + obj=cli_state_with_user, + ) + assert result.exit_code == 2 + assert ( + "Invalid value for '--departure-date': invalid datetime format" in result.output + ) From eedb28edfea946a29568854be267f37b8b3fb438 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 27 Jul 2020 12:03:20 -0500 Subject: [PATCH 099/349] Update profile.md (#122) --- docs/userguides/profile.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md index 4a0da4823..e19a3b6ee 100644 --- a/docs/userguides/profile.md +++ b/docs/userguides/profile.md @@ -8,8 +8,8 @@ First, create your profile: code42 profile create --name MY_FIRST_PROFILE --server example.authority.com --username security.admin@example.com ``` -Your profile contains the necessary properties for logging into Code42 servers. After running `code42 profile create`, -the program prompts you about storing a password. If you agree, you are then prompted to input your password. +Your profile contains the necessary properties for authenticating with Code42. After running `code42 profile create`, +the program prompts you about storing a password. If you agree, you are then prompted to enter your password. Your password is not shown when you do `code42 profile show`. However, `code42 profile show` will confirm that a password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each @@ -21,8 +21,8 @@ You can add multiple profiles with different names and the change the default pr code42 profile use MY_SECOND_PROFILE ``` -When the `--profile` flag is available on other commands, such as those in `security-data`, it will use that profile -instead of the default one. For example, +When you use the `--profile` flag with other commands, such as those in `security-data`, that profile is used +instead of the default profile. For example, ```bash code42 security-data search -b 2020-02-02 --profile MY_SECOND_PROFILE From 4d48c19f1f4dffc6926da2f3f257f2ecee800305 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 27 Jul 2020 17:18:01 -0500 Subject: [PATCH 100/349] Update siemexample.md (#123) Co-authored-by: Alan Grgic --- docs/userguides/siemexample.md | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index 2daae6d1d..37d1893b8 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -1,24 +1,19 @@ -# Integrating with SIEM Tools +# Ingest file event data into a SIEM tool -The Code42 command-line interface (CLI) tool offers a way to interact with your Code42 environment without using the -Code42 console or making API calls directly. This article provides instructions on using the CLI to extract Code42 data -for use in a security information and event management (SIEM) tool like LogRhythm, Sumo Logic, or IBM QRadar. - -You can also use the Code42 CLI to bulk-add or remove users from the High Risk Employees list or Departing Employees -list. For more information, see Manage detection list users with the Code42 command-line interface. +This guide provides instructions on using the CLI to ingest Code42 file event data +into a security information and event management (SIEM) tool like LogRhythm, Sumo Logic, or IBM QRadar. ## Considerations -To integrate with a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration -must be assigned roles that provide the necessary permissions. We recommend you assign the roles in our use case for -managing a security application integrated with Code42. +To ingest file events into a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration +must be assigned roles that provide the necessary permissions. ## Before you begin -To integrate Code42 with a SIEM tool, you must first install and configure the Code42 CLI following the instructions in -[Getting Started](gettingstarted.md) the Code42 command-line interface. +First install and configure the Code42 CLI following the instructions in +[Getting Started](gettingstarted.md). -## Commands and query parameters +## Run file event queries You can get security events in either a JSON or CEF format for use by your SIEM tool. You can query the data as a scheduled job or run ad-hoc queries. Learn more about [searching](../commands/securitydata.md) using the CLI. @@ -31,8 +26,7 @@ the profile to use by including `--profile`. An example using `netcat` to forwar code42 security-data search --profile profile1 -c syslog_sender | nc syslog.example.com 514 ``` -Note that it is best practice to use a separate profile when executing a scheduled task. This way, it is harder to -accidentally mess up your stored checkpoints by running `--use-checkpoint` in adhoc queries. +As a best practice, use a separate profile when executing a scheduled task. Using separate profiles can help prevent accidental updates to your stored checkpoints, for example, by adding `--use-checkpoint` to adhoc queries. This query will send to the syslog server only the new security event data since the previous request. @@ -101,7 +95,7 @@ The following tables map the data from the Code42 CLI to common event format (CE ### Attribute mapping -The table below maps JSON fields, CEF fields, and [Forensic Search fields](https://support.code42.com/Administrator/Cloud/Administration_console_reference/Forensic_Search_reference_guide) +The table below maps JSON fields, CEF fields, and [Forensic Search fields](https://code42.com/r/support/forensic-search-fields) to one another. ```eval_rst From ea94fc4193636503b25d403079a1b73b6d2f8af7 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 27 Jul 2020 17:18:30 -0500 Subject: [PATCH 101/349] Update detectionlists.md (#124) Co-authored-by: Alan Grgic --- docs/userguides/detectionlists.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/userguides/detectionlists.md b/docs/userguides/detectionlists.md index 3714a65c6..2e7a9142c 100644 --- a/docs/userguides/detectionlists.md +++ b/docs/userguides/detectionlists.md @@ -1,6 +1,6 @@ -# Managing Detection List Users +# Manage Detection List Users -Use the departing-employee commands to add or remove employees on that that list, or update the details for a user. To +Use the `departing-employee` commands to add or remove employees on the Departing Employees list or High Risk list, or update the details for a user. To see a list of all the users currently in your organization, you can export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Administration_console_reference/Users_reference#Action_menu). @@ -23,7 +23,7 @@ employee. ## Add users to the Departing Employees list -Once you have entered the employees' information in the CSV file, use the bulk add command with the CSV file path to +Once you have entered the employees' information in the CSV file, use the `bulk add` command with the CSV file path to add multiple users at once. For example: ```bash @@ -40,11 +40,11 @@ To remove multiple users at once: 2. Save the file to your current working directory. -3. Use the bulk remove command. For example: +3. Use the `bulk remove` command. For example: ```bash code42 high-risk-employee bulk remove /Users/matt.allen/remove_high_risk_employee.csv ``` -Learn more about the [Departing Employee](../commands/departingemployee.md) and the +Learn more about the [Departing Employee](../commands/departingemployee.md) and [High Risk Employee](../commands/highriskemployee.md) commands. From 2632b74c0f73ca490cf7bc0ce29b81c3c46ab4a1 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 27 Jul 2020 17:32:59 -0500 Subject: [PATCH 102/349] Enable Nightly Builds (#125) * attempt at nightly builds from master branches * limit to master branch * that wasn't right * test dispatch * use workflow dispatch instead * try https * try eventextractor with ssh key * add username * remove from envlist and style * fix .iml * remove .idea/ and add to gitignore --- .github/workflows/nightly.yml | 35 +++++++++++++++++++++++++++++++++++ .gitignore | 3 +++ .idea/.gitignore | 0 .idea/code42cli.iml | 19 ------------------- tox.ini | 9 +++++++++ 5 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/nightly.yml delete mode 100644 .idea/.gitignore delete mode 100644 .idea/code42cli.iml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..165094a94 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,35 @@ +name: nightly + +on: + workflow_dispatch: + schedule: + - cron: '0 5 * * *' + +jobs: + nightly: + + runs-on: ubuntu-latest + strategy: + matrix: + python: [3.5, 3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python }} + - name: Setup SSH Keys and known_hosts + env: + SSH_AUTH_SOCK: /tmp/ssh_agent.sock + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + ssh-agent -a $SSH_AUTH_SOCK > /dev/null + ssh-add - <<< "${{ secrets.C42_EVENT_EXTRACTOR_PRIVATE_DEPLOY_KEY }}" + - name: Install tox + run: pip install tox==3.17.1 + - name: Run Unit tests + env: + SSH_AUTH_SOCK: /tmp/ssh_agent.sock + run: tox -e nightly # Run tox using latest master branch from py42/c42eventextractor diff --git a/.gitignore b/.gitignore index a18068a9c..a1db36551 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ __pycache__/ # C extensions *.so +# IDE files +.idea + # Distribution / packaging .Python build/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index e69de29bb..000000000 diff --git a/.idea/code42cli.iml b/.idea/code42cli.iml deleted file mode 100644 index f13aa5daa..000000000 --- a/.idea/code42cli.iml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/tox.ini b/tox.ini index 56b8d5f63..ee7d62f75 100644 --- a/tox.ini +++ b/tox.ini @@ -31,3 +31,12 @@ commands = sphinx-build -W -b html -d {envtmpdir}/doctress docs {envtmpdir}/html deps = pre-commit skip_install = true commands = pre-commit run --all-files --show-diff-on-failure + + +[testenv:nightly] +deps = + pytest == 4.6.11 + pytest-mock == 2.0.0 + pytest-cov == 2.10.0 + git+https://github.com/code42/py42.git@master#egg=py42 + git+ssh://git@github.com/code42/c42eventextractor.git@master#egg=c42eventextractor From e54db83b22621259de28375f357201dc9f9a6cfc Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 28 Jul 2020 14:11:18 -0500 Subject: [PATCH 103/349] Chore/click autodoc (#118) * implement @quiet_sdk_options * add sphinx_click * update command docs to use sphinx-click * remove a test thing * add sphinx-click to tox.ini * use the right module name * tox style fixes * add doc requirements for readthedocs * rework quiet_sdk_option into a flag instead of separate decorator --- docs/commands.md | 12 +-- docs/commands/alertrules.md | 84 -------------------- docs/commands/alertrules.rst | 3 + docs/commands/alerts.md | 51 ------------ docs/commands/alerts.rst | 3 + docs/commands/departingemployee.md | 65 ---------------- docs/commands/departingemployee.rst | 3 + docs/commands/highriskemployee.md | 97 ----------------------- docs/commands/highriskemployee.rst | 3 + docs/commands/profile.md | 99 ------------------------ docs/commands/profile.rst | 3 + docs/commands/securitydata.md | 50 ------------ docs/commands/securitydata.rst | 3 + docs/conf.py | 7 +- docs/requirements.txt | 2 + src/code42cli/cmds/alert_rules.py | 16 ++-- src/code42cli/cmds/alerts.py | 6 +- src/code42cli/cmds/departing_employee.py | 12 +-- src/code42cli/cmds/high_risk_employee.py | 20 ++--- src/code42cli/cmds/legal_hold.py | 16 ++-- src/code42cli/cmds/securitydata.py | 12 +-- src/code42cli/main.py | 2 +- src/code42cli/options.py | 51 +++++++----- tox.ini | 1 + 24 files changed, 107 insertions(+), 514 deletions(-) delete mode 100644 docs/commands/alertrules.md create mode 100644 docs/commands/alertrules.rst delete mode 100644 docs/commands/alerts.md create mode 100644 docs/commands/alerts.rst delete mode 100644 docs/commands/departingemployee.md create mode 100644 docs/commands/departingemployee.rst delete mode 100644 docs/commands/highriskemployee.md create mode 100644 docs/commands/highriskemployee.rst delete mode 100644 docs/commands/profile.md create mode 100644 docs/commands/profile.rst delete mode 100644 docs/commands/securitydata.md create mode 100644 docs/commands/securitydata.rst create mode 100644 docs/requirements.txt diff --git a/docs/commands.md b/docs/commands.md index 3001b102f..59e5d9002 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,8 +1,8 @@ # Commands -* [Profile](commands/profile.md) -* [Security Data](commands/securitydata.md) -* [Alerts](commands/alerts.md) -* [Alert Rules](commands/alertrules.md) -* [Departing Employee](commands/departingemployee.md) -* [High Risk Employee](commands/highriskemployee.md) +* [Profile](commands/profile.rst) +* [Security Data](commands/securitydata.rst) +* [Alerts](commands/alerts.rst) +* [Alert Rules](commands/alertrules.rst) +* [Departing Employee](commands/departingemployee.rst) +* [High Risk Employee](commands/highriskemployee.rst) diff --git a/docs/commands/alertrules.md b/docs/commands/alertrules.md deleted file mode 100644 index 13aabf8c5..000000000 --- a/docs/commands/alertrules.md +++ /dev/null @@ -1,84 +0,0 @@ -# Alert Rules - -## add-user - -Add a user to a given alert rule. - -Arguments: -* `--rule-id`: Observer ID of the rule to be updated. -* `--username`, `-u` The username of the user to add to the alert rule. - -Usage: -```bash -code42 alert-rules add-user --rule-id --username -``` - -## remove-user - -Remove a user to a given alert rule. - -Arguments: -* `--rule-id`: Observer ID of the rule to be updated. -* `--username`, `-u`: The username of the user to remove from the alert rule. - -Usage: -```bash -code42 alert-rules remove-user --rule-id --username -``` - -## list - -Fetch existing alert rules. - -Usage: -```bash -code42 alert-rules list -``` - -## show - -Print out detailed alert rule criteria. - -Arguments: -* `rule-id`: Observer ID of the rule. - -Usage: -```bash -code42 alert-rules show -``` - -## bulk generate-template - -Generate the necessary csv template for bulk actions. - -Arguments: -* `cmd`: The type of command the template will be used for. Available choices= [add, remove]. - -Usage: -```bash -code42 alert-rules bulk generate-template -``` - -## bulk add - -Add users to alert rules. CSV file format: `rule_id,username`. - -Arguments: -* `file-name`: The path to the csv file with columns 'rule_id,username' for bulk adding users to the alert rule. - -Usage: -```bash -code42 alert-rules bulk add -``` - -## bulk remove - -Remove users from alert rules. CSV file format: `rule_id,username`. - -Arguments: -* `file-name`: The path to the csv file with columns 'rule_id,username' for bulk removing users to the alert rule. - -Usage: -```bash -code42 alert-rules bulk remove -``` diff --git a/docs/commands/alertrules.rst b/docs/commands/alertrules.rst new file mode 100644 index 000000000..2b89041e9 --- /dev/null +++ b/docs/commands/alertrules.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.alert_rules:alert_rules + :prog: alert-rules + :show-nested: diff --git a/docs/commands/alerts.md b/docs/commands/alerts.md deleted file mode 100644 index 2fb03aac0..000000000 --- a/docs/commands/alerts.md +++ /dev/null @@ -1,51 +0,0 @@ -# Alerts - -## search - -Search for alerts and print them to stdout. - -Arguments: -* `advanced-query`: A raw JSON alerts query. Useful for when the provided query parameters do not satisfy your - requirements. WARNING: Using advanced queries is incompatible with other query-building args. -* `-b`, `--begin`: The beginning of the date range in which to look for alerts, can be a date/time in yyyy-MM-dd (UTC) - or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' portion of the string can be partial - (e.g. '2020-01-01 12' or '2020-01-01 01:15') or a short value representing days (30d), hours (24h) or minutes (15m) - from current time. -* `-e`, `--end`: The end of the date range in which to look for alerts, argument format options are the same as --begin. -* `--severity`: Filter alerts by severity. Defaults to returning all severities. - Available choices=['HIGH', 'MEDIUM', 'LOW'] -* `--state`: Filter alerts by state. Defaults to returning all states. Available choices=['OPEN', 'RESOLVED']. -* `--actor`: Filter alerts by including the given actor(s) who triggered the alert. Args must match actor username - exactly. -* `--actor-contains`: Filter alerts by including actor(s) whose username contains the given string. -* `--exclude-actor`: Filter alerts by excluding the given actor(s) who triggered the alert. Args must match actor - username exactly. -* `--exclude-actor-contains`: Filter alerts by excluding actor(s) whose username contains the given string. -* `--rule-name`: Filter alerts by including the given rule name(s). -* `--exclude-rule-name`: Filter alerts by excluding the given rule name(s). -* `--rule-id`: Filter alerts by including the given rule id(s). -* `--exclude-rule-id`: Filter alerts by excluding the given rule id(s). -* `--rule-type`: Filter alerts by including the given rule type(s). - Available choices=['FedEndpointExfiltration', 'FedCloudSharePermissions', 'FedFileTypeMismatch']. -* `--exclude-rule-type`: Filter alerts by excluding the given rule type(s). - Available choices=['FedEndpointExfiltration', 'FedCloudSharePermissions', 'FedFileTypeMismatch']. -* `--description`: Filter alerts by description. Does fuzzy search by default. -* `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. -* `-c`, `--use-checkpoint` (optional): Get only file events that were not previously retrieved by writing the timestamp of the last event retrieved to a named checkpoint. - -Usage: -```bash -code42 alerts search -b -``` - -## clear-checkpoint - -Arguments: -* `name`: The name to save this checkpoint as for later reuse. - -Remove the saved file event checkpoint from 'use-checkpoint' (-c) mode. - -Usage: -```bash -code42 alerts clear-checkpoint -``` diff --git a/docs/commands/alerts.rst b/docs/commands/alerts.rst new file mode 100644 index 000000000..45e5cf249 --- /dev/null +++ b/docs/commands/alerts.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.alerts:alerts + :prog: alerts + :show-nested: diff --git a/docs/commands/departingemployee.md b/docs/commands/departingemployee.md deleted file mode 100644 index 35d5a5210..000000000 --- a/docs/commands/departingemployee.md +++ /dev/null @@ -1,65 +0,0 @@ -# Departing Employee - -## add - -Add a user to the departing-employee detection list. - -Arguments: -* `username`: A Code42 username for an employee. -* `--cloud-alias` (optional): An alternative email address for another cloud service. -* `--departure-date` (optional): The date the employee is departing in format yyyy-MM-dd. -* `--notes` (optional): Notes about the employee. - -Usage: -```bash -code42 departing-employee add -``` - -## remove - -Remove a user from the departing-employee detection list. - -Arguments: -* `username`: A Code42 username for an employee. - -Usage: -```bash -code42 departing-employee remove -``` - -## bulk generate-template - -Generate the necessary csv template for bulk actions. - -Arguments: -* `cmd`: The type of command the template will be used for. Available choices= [add, remove]. - -Usage: -```bash -code42 departing-employee bulk generate-template -``` - -## bulk add - -Bulk add users to the departing-employee detection list using a csv file. - -Arguments: -* `filename`: The path to the csv file for bulk adding users to the departing-employee detection list. - -Usage: -```bash -code42 departing-employee bulk add -``` - -## bulk remove - -Bulk remove users from the departing-employee detection list using a file. - -Arguments: -* `users-file`: A file containing a line-separated list of users to remove form the departing-employee detection - list. - -Usage: -```bash -code42 departing-employee bulk remove -``` diff --git a/docs/commands/departingemployee.rst b/docs/commands/departingemployee.rst new file mode 100644 index 000000000..56c4cf8cd --- /dev/null +++ b/docs/commands/departingemployee.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.departing_employee:departing_employee + :prog: departing-employee + :show-nested: diff --git a/docs/commands/highriskemployee.md b/docs/commands/highriskemployee.md deleted file mode 100644 index 63428d82e..000000000 --- a/docs/commands/highriskemployee.md +++ /dev/null @@ -1,97 +0,0 @@ -# High Risk Employee - -## add - -Add a user to the high-risk-employee detection list. - -Arguments: -* `username`: A Code42 username for an employee. -* `--cloud-alias` (optional): An alternative email address for another cloud service. -* `-risk-tag` (optional): Risk tags associated with the user. Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, - ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, - CONTRACT_EMPLOYEE]. -* `--notes` (optional): Notes about the employee. - -Usage: -```bash -code42 high-risk-employee add -``` - -## remove - -Remove a user from the high-risk-employee detection list. - - Arguments: -* `username`: A Code42 username for an employee. - -Usage: -```bash -code42 high-risk-employee remove -``` - -## add-risk-tags - -Associates risk tags with a user. - -Arguments: -* `--username`, `-u`: A Code42 username for an employee. -* `--tag`: Risk tags associated with the employee. - Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, - SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, CONTRACT_EMPLOYEE]. - -Usage: -```bash -code42 high-risk-employee add-risk-tags --username --tag -``` - -## remove-risk-tags - -Disassociates risk tags from a user. - -Arguments: -* `--username`, `-u`: A Code42 username for an employee. -* `--tag`: Risk tags associated with the employee. - Options include: [FLIGHT_RISK, HIGH_IMPACT_EMPLOYEE, ELEVATED_ACCESS_PRIVILEGES, PERFORMANCE_CONCERNS, - SUSPICIOUS_SYSTEM_ACTIVITY, POOR_SECURITY_PRACTICES, CONTRACT_EMPLOYEE]. - -Usage: -```bash -code42 high-risk-employee remove-risk-tags --username --tag -``` - -## bulk generate-template - -Generate the necessary csv template for bulk actions. - -Arguments: -* `cmd`: The type of command the template will be used for. Available choices= [add, remove]. - -Usage: -```bash -code42 high-risk-employee bulk generate-template -``` - -## bulk add - -Bulk add users to the high-risk-employee detection list using a csv file. - -Arguments: -* `filename`: The path to the csv file for bulk adding users to the high-risk-employee detection list. - -Usage: -```bash -code42 high-risk-employee bulk add -``` - -## bulk remove - -Bulk remove users from the high-risk-employee detection list using a file. - -Arguments: -* `users-file`: A file containing a line-separated list of users to remove form the high-risk-employee detection - list. - -Usage: -```bash -code42 high-risk-employee bulk remove -``` diff --git a/docs/commands/highriskemployee.rst b/docs/commands/highriskemployee.rst new file mode 100644 index 000000000..8ca35d318 --- /dev/null +++ b/docs/commands/highriskemployee.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.high_risk_employee:high_risk_employee + :prog: high-risk-employee + :show-nested: diff --git a/docs/commands/profile.md b/docs/commands/profile.md deleted file mode 100644 index 215b2dbe5..000000000 --- a/docs/commands/profile.md +++ /dev/null @@ -1,99 +0,0 @@ -# Profile Commands - -## show - -Print the details of a profile. - -Arguments: -* `--name`, `-n` (optional): The name of the Code42 profile to use when executing this command. - -Usage: -```bash -code42 profile show -``` - -## list - -Show all existing stored profiles. - -Usage: -```bash -code42 profile list -``` - -## use - -Set a profile as the default. - -Arguments: -* `name`: The name of the profile to set as active. - -Usage: -```bash -code42 profile use -``` - -## reset-pw - -Change the stored password for a profile. - -Arguments: -* `--name`, `-n` (optional): The name of the Code42 profile to use when executing this command. - -Usage: -```bash -code42 profile reset-pw -``` - -## create - -Create profile settings. The first profile created will be the default. - -Arguments: -* `--name`, `-n`: The name of the code42cli profile to use when executing this command. -* `--server`, `-s`: The url and port of the Code42 server. -* `--username`, `-u`: The username of the Code42 API user. -* `--disable-ssl-errors` (optional): For development purposes, do not validate the SSL certificates of Code42 servers. - This is not recommended unless it is required. - -Usage: -```bash -code42 profile create --name --server --username -``` - -## update - -Update an existing profile. - -Arguments: -* `--name`, `-n`: The name of the code42cli profile to use when executing this command. -* `--server`, `-s`: The url and port of the Code42 server. -* `--username`, `-u`: The username of the Code42 API user. -* `--disable-ssl-errors` (optional): For development purposes, do not validate the SSL certificates of Code42 servers. - This is not recommended unless it is required. - -Usage: -```bash -code42 profile update -``` - -## delete - -Deletes a profile and its stored password (if any). - -Arguments: -* `name`: The name of the code42cli profile you wish to delete. - -Usage: -```bash -code42 profile delete -``` - -## delete-all - -Deletes all profiles and saved passwords (if any). - -Usage: -```bash -code42 profile delete-all -``` diff --git a/docs/commands/profile.rst b/docs/commands/profile.rst new file mode 100644 index 000000000..73f4ca712 --- /dev/null +++ b/docs/commands/profile.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.profile:profile + :prog: profile + :show-nested: diff --git a/docs/commands/securitydata.md b/docs/commands/securitydata.md deleted file mode 100644 index 7ea93aabc..000000000 --- a/docs/commands/securitydata.md +++ /dev/null @@ -1,50 +0,0 @@ -# Security Data - - -## search - -Search for file events and print them to stdout. - -Arguments: -* `--advanced-query` (optional | cannot be used with other query options): A raw JSON file events query. Useful for when the provided query parameters do not - satisfy your requirements. WARNING: Using advanced queries is incompatible with other query-building args. -* `--saved-search` (optional | cannot be used with other query options): Get events from a saved search filter (created in the Code42 admin console) with the given ID. -* `-b`, `--begin` (required except for non-first runs in checkpoint mode): The beginning of the date range in which to - look for file events, can be a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where - the 'time' portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') or a short value - representing days (30d), hours (24h) or minutes (15m) from current time. -* `-e`, `--end` (optional): The end of the date range in which to look for file events, argument format options are the - same as `--begin`. -* `-t`, `--type` (optional): Limits events to those with given exposure types. Available choices= - ['SharedViaLink', 'SharedToDomain', 'ApplicationRead', 'CloudStorage', 'RemovableMedia', 'IsPublic'] -* `--c42-username` (optional): Limits events to endpoint events for these users. -* `--actor` (optional): Limits events to only those enacted by the cloud service user of the person who caused the event. -* `--md5` (optional): Limits events to file events where the file has one of these MD5 hashes. -* `--sha256` (optional): Limits events to file events where the file has one of these SHA256 hashes. -* `--source` (optional): Limits events to only those from one of these sources. Example=Gmail. -* `--file-name` (optional): Limits events to file events where the file has one of these names. -* `--file-path` (optional): Limits events to file events where the file is located at one of these paths. -* `--process-owner` (optional): Limits events to exposure events where one of these users owns the process behind the - exposure. -* `--tab-url` (optional): Limits events to be exposure events with one of these destination tab URLs. -* `--include-non-exposure` (optional): Get all events including non-exposure events. -* `-f`, `--format` (optional): The format used for outputting file events. Available choices= [CEF,JSON,RAW-JSON]. -* `-c`, `--use-checkpoint` (optional): Get only file events that were not previously retrieved by writing the timestamp of the last event retrieved to a named checkpoint. - -Usage: -```bash -code42 security-data search -b -``` - - -## clear-checkpoint - -Arguments: -* `name`: The name to save this checkpoint as for later reuse. - -Remove the saved file event checkpoint from 'use-checkpoint' (-c) mode. - -Usage: -```bash -code42 security-data clear-checkpoint -``` diff --git a/docs/commands/securitydata.rst b/docs/commands/securitydata.rst new file mode 100644 index 000000000..76e908269 --- /dev/null +++ b/docs/commands/securitydata.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.securitydata:security_data + :prog: security-data + :show-nested: diff --git a/docs/conf.py b/docs/conf.py index 4844881d9..ed8b9e9f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,7 +37,12 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "recommonmark"] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "recommonmark", + "sphinx_click", +] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..37ab4af69 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +click==7.1.2 +sphinx-click==2.5.0 diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index b83e08022..a6ea67407 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -34,7 +34,7 @@ class AlertRuleTypes: @click.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def alert_rules(state): """Manage alert rules.""" pass @@ -54,7 +54,7 @@ def alert_rules(state): required=True, help="The username of the user to add to the alert rule.", ) -@sdk_options +@sdk_options() def add_user(state, rule_id, username): """Add a user to an alert rule.""" _add_user(state.sdk, rule_id, username) @@ -68,14 +68,14 @@ def add_user(state, rule_id, username): required=True, help="The username of the user to remove from the alert rule.", ) -@sdk_options +@sdk_options() def remove_user(state, rule_id, username): """Remove a user from an alert rule.""" _remove_user(state.sdk, rule_id, username) @alert_rules.command("list") -@sdk_options +@sdk_options() def list_alert_rules(state): """Fetch existing alert rules.""" selected_rules = _get_all_rules_metadata(state.sdk) @@ -86,7 +86,7 @@ def list_alert_rules(state): @alert_rules.command() @click.argument("rule_id") -@sdk_options +@sdk_options() def show(state, rule_id): """Print out detailed alert rule criteria.""" selected_rule = _get_rule_metadata(state.sdk, rule_id) @@ -97,7 +97,7 @@ def show(state, rule_id): @alert_rules.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def bulk(state): """Tools for executing bulk alert rule actions.""" pass @@ -118,7 +118,7 @@ def bulk(state): ) ) @read_csv_arg(headers=ALERT_RULES_CSV_HEADERS) -@sdk_options +@sdk_options() def add(state, csv_rows): sdk = state.sdk @@ -136,7 +136,7 @@ def handle_row(rule_id, username): ) ) @read_csv_arg(headers=ALERT_RULES_CSV_HEADERS) -@sdk_options +@sdk_options() def remove(state, csv_rows): sdk = state.sdk diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 0c23989c5..7d3f63b61 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -137,7 +137,7 @@ def alert_options(f): @click.group(cls=opt.OrderedGroup) -@opt.sdk_options +@opt.sdk_options(hidden=True) def alerts(state): """Tools for getting alert data.""" # store cursor getter on the group state so shared --begin option can use it in validation @@ -146,7 +146,7 @@ def alerts(state): @alerts.command() @click.argument("checkpoint-name") -@opt.sdk_options +@opt.sdk_options() def clear_checkpoint(state, checkpoint_name): """Remove the saved alert checkpoint from '--use-checkpoint/-c' mode.""" _get_alert_cursor_store(state.profile.name).delete(checkpoint_name) @@ -158,7 +158,7 @@ def clear_checkpoint(state, checkpoint_name): @click.option( "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible ) -@opt.sdk_options +@opt.sdk_options() def search( cli_state, format, begin, end, advanced_query, use_checkpoint, or_query, **kwargs ): diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index d0963e5d4..a084abc94 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -16,7 +16,7 @@ @click.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def departing_employee(state): """For adding and removing employees from the departing employee detection list.""" pass @@ -31,7 +31,7 @@ def departing_employee(state): ) @cloud_alias_option @notes_option -@sdk_options +@sdk_options() def add(state, username, cloud_alias, departure_date, notes): """Add a user to the departing-employee detection list.""" if departure_date: @@ -41,14 +41,14 @@ def add(state, username, cloud_alias, departure_date, notes): @departing_employee.command() @username_arg -@sdk_options +@sdk_options() def remove(state, username): """Remove a user from the departing-employee detection list.""" _remove_departing_employee(state.sdk, username) @departing_employee.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def bulk(state): """Tools for executing bulk departing employee actions.""" pass @@ -69,7 +69,7 @@ def bulk(state): "format: {}".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) -@sdk_options +@sdk_options() def bulk_add(state, csv_rows): sdk = state.sdk @@ -89,7 +89,7 @@ def handle_row(username, cloud_alias, departure_date, notes): "file of usernames.", ) @read_flat_file_arg -@sdk_options +@sdk_options() def bulk_remove(state, file_rows): sdk = state.sdk diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index fa6c51fb0..5874dd4d7 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -28,7 +28,7 @@ @click.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def high_risk_employee(state): """For adding and removing employees from the high risk employee detection list.""" pass @@ -39,7 +39,7 @@ def high_risk_employee(state): @notes_option @risk_tag_option @username_arg -@sdk_options +@sdk_options() def add(state, username, cloud_alias, risk_tag, notes): """Add a user to the high-risk-employee detection list.""" _add_high_risk_employee(state.sdk, username, cloud_alias, risk_tag, notes) @@ -47,7 +47,7 @@ def add(state, username, cloud_alias, risk_tag, notes): @high_risk_employee.command() @username_arg -@sdk_options +@sdk_options() def remove(state, username): """Remove a user from the high-risk-employee detection list.""" _remove_high_risk_employee(state.sdk, username) @@ -56,7 +56,7 @@ def remove(state, username): @high_risk_employee.command() @username_arg @risk_tag_option -@sdk_options +@sdk_options() def add_risk_tags(state, username, risk_tag): """Associates risk tags with a user.""" _add_risk_tags(state.sdk, username, risk_tag) @@ -65,14 +65,14 @@ def add_risk_tags(state, username, risk_tag): @high_risk_employee.command() @username_arg @risk_tag_option -@sdk_options +@sdk_options() def remove_risk_tags(state, username, risk_tag): """Disassociates risk tags from a user.""" _remove_risk_tags(state.sdk, username, risk_tag) @high_risk_employee.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def bulk(state): """Tools for executing bulk high risk employee actions.""" pass @@ -99,7 +99,7 @@ def bulk(state): "format: {}".format(",".join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) -@sdk_options +@sdk_options() def bulk_add(state, csv_rows): sdk = state.sdk @@ -119,7 +119,7 @@ def handle_row(username, cloud_alias, risk_tag, notes): "file of usernames.", ) @read_flat_file_arg -@sdk_options +@sdk_options() def bulk_remove(state, file_rows): sdk = state.sdk @@ -140,7 +140,7 @@ def handle_row(username): ), ) @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) -@sdk_options +@sdk_options() def bulk_add_risk_tags(state, csv_rows): sdk = state.sdk @@ -159,7 +159,7 @@ def handle_row(username, tag): ), ) @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) -@sdk_options +@sdk_options() def bulk_remove_risk_tags(state, csv_rows): sdk = state.sdk diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 780f0d25f..f039b44fa 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -30,7 +30,7 @@ @click.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def legal_hold(state): """For adding and removing employees to legal hold matters.""" pass @@ -55,7 +55,7 @@ def legal_hold(state): @legal_hold.command() @matter_id_option @user_id_option -@sdk_options +@sdk_options() def add_user(state, matter_id, username): """Add a user to a legal hold matter.""" _add_user_to_legal_hold(state.sdk, matter_id, username) @@ -64,14 +64,14 @@ def add_user(state, matter_id, username): @legal_hold.command() @matter_id_option @user_id_option -@sdk_options +@sdk_options() def remove_user(state, matter_id, username): """Remove a user from a legal hold matter.""" _remove_user_from_legal_hold(state.sdk, matter_id, username) @legal_hold.command("list") -@sdk_options +@sdk_options() def _list(state): """Fetch existing legal hold matters.""" matters = _get_all_active_matters(state.sdk) @@ -84,7 +84,7 @@ def _list(state): @click.argument("matter-id") @click.option("--include-inactive", is_flag=True) @click.option("--include-policy", is_flag=True) -@sdk_options +@sdk_options() def show(state, matter_id, include_inactive=False, include_policy=False): """Display details of a given legal hold matter.""" matter = _check_matter_is_accessible(state.sdk, matter_id) @@ -118,7 +118,7 @@ def show(state, matter_id, include_inactive=False, include_policy=False): @legal_hold.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def bulk(state): """Tools for executing bulk legal hold actions.""" pass @@ -141,7 +141,7 @@ def bulk(state): ), ) @read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) -@sdk_options +@sdk_options() def bulk_add(state, csv_rows): sdk = state.sdk @@ -157,7 +157,7 @@ def handle_row(matter_id, username): ) ) @read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) -@sdk_options +@sdk_options() def remove(state, csv_rows): sdk = state.sdk diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index c8f560084..f38913f8b 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -145,7 +145,7 @@ def file_event_options(f): @click.group(cls=OrderedGroup) -@sdk_options +@sdk_options(hidden=True) def security_data(state): """Tools for getting security related data, such as file events.""" # store cursor getter on the group state so shared --begin option can use it in validation @@ -154,7 +154,7 @@ def security_data(state): @security_data.command() @click.argument("checkpoint-name") -@sdk_options +@sdk_options() def clear_checkpoint(state, checkpoint_name): """Remove the saved file event checkpoint from '--use-checkpoint/-c' mode.""" _get_file_event_cursor_store(state.profile.name).delete(checkpoint_name) @@ -166,7 +166,7 @@ def clear_checkpoint(state, checkpoint_name): @click.option( "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible ) -@sdk_options +@sdk_options() def search( state, format, @@ -204,13 +204,13 @@ def search( @security_data.group(cls=OrderedGroup) -@sdk_options +@sdk_options() def saved_search(state): pass @saved_search.command("list") -@sdk_options +@sdk_options() def _list(state): """List available saved searches.""" response = state.sdk.securitydata.savedsearches.get() @@ -220,7 +220,7 @@ def _list(state): @saved_search.command() @click.argument("search-id") -@sdk_options +@sdk_options() def show(state, search_id): """Get the details of a saved search.""" response = state.sdk.securitydata.savedsearches.get_by_id(search_id) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 45e603e8f..96e44f222 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -48,7 +48,7 @@ def exit_on_interrupt(signal, frame): @click.group(cls=ExceptionHandlingGroup, context_settings=CONTEXT_SETTINGS, help=BANNER) -@sdk_options +@sdk_options(hidden=True) def cli(state): pass diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 356f6e416..8556265e2 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -62,28 +62,41 @@ def set_debug(ctx, param, value): ctx.ensure_object(CLIState).debug = value -profile_option = click.option( - "--profile", - expose_value=False, - callback=set_profile, - help="The name of the Code42 CLI profile to use when executing this command.", -) -debug_option = click.option( - "-d", - "--debug", - is_flag=True, - expose_value=False, - callback=set_debug, - help="Turn on debug logging.", -) +def profile_option(hidden=False): + opt = click.option( + "--profile", + expose_value=False, + callback=set_profile, + hidden=hidden, + help="The name of the Code42 CLI profile to use when executing this command.", + ) + return opt + + +def debug_option(hidden=False): + opt = click.option( + "-d", + "--debug", + is_flag=True, + expose_value=False, + callback=set_debug, + hidden=hidden, + help="Turn on debug logging.", + ) + return opt + + pass_state = click.make_pass_decorator(CLIState, ensure=True) -def sdk_options(f): - f = profile_option(f) - f = debug_option(f) - f = pass_state(f) - return f +def sdk_options(hidden=False): + def decorator(f): + f = profile_option(hidden)(f) + f = debug_option(hidden)(f) + f = pass_state(f) + return f + + return decorator def incompatible_with(incompatible_opts): diff --git a/tox.ini b/tox.ini index ee7d62f75..444692010 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,7 @@ deps = sphinx recommonmark sphinx_rtd_theme + sphinx-click commands = sphinx-build -W -b html -d {envtmpdir}/doctress docs {envtmpdir}/html From d0e037a8990c7341f9e6ad29fb35e3915b8d6831 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 29 Jul 2020 09:49:15 -0500 Subject: [PATCH 104/349] bump version and add missing changelog items (#126) --- CHANGELOG.md | 4 +++- src/code42cli/__version__.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 568d491dc..372ba3cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,9 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added -- Profile can now save multiple alert and file event checkpoints. The name of the checkpoint to be used for a given query should be passed to `-c` (`--use-checkpoint`). +- `--or-query` option added to `security-data search` and `alerts search` commands which combines the provided filter arguments into an 'OR' query instead of the default 'AND' query. +- `--password` option added to `profile create` and `profile update` commands, enabling creating profiles while bypassing the interactive password prompt. +- Profiles can now save multiple alert and file event checkpoints. The name of the checkpoint to be used for a given query should be passed to `-c` (`--use-checkpoint`). - `-y/--assume-yes` option added to `profile delete` and `profile delete-all` commands to not require interactive prompt. ### Removed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 531382306..846359f62 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.0.0b1" +__version__ = "1.0.0b2" From 6887deeaf30c871aa2fd43cd827bbe6fdb8deff2 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 29 Jul 2020 10:50:25 -0500 Subject: [PATCH 105/349] Add legalhold cmd docs and userguide (#127) * add legalhold cmd docs and userguide * tox style fixes * add reference * missing backticks * rework generate template language * genericize user info --- docs/commands.md | 1 + docs/commands/legalhold.rst | 3 ++ docs/guides.md | 1 + docs/userguides/legalhold.md | 96 ++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 docs/commands/legalhold.rst create mode 100644 docs/userguides/legalhold.md diff --git a/docs/commands.md b/docs/commands.md index 59e5d9002..a0fe8adef 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,3 +6,4 @@ * [Alert Rules](commands/alertrules.rst) * [Departing Employee](commands/departingemployee.rst) * [High Risk Employee](commands/highriskemployee.rst) +* [Legal Hold](commands/legalhold.rst) diff --git a/docs/commands/legalhold.rst b/docs/commands/legalhold.rst new file mode 100644 index 000000000..36a87bc87 --- /dev/null +++ b/docs/commands/legalhold.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.legal_hold:legal_hold + :prog: legal-hold + :show-nested: diff --git a/docs/guides.md b/docs/guides.md index 4b838d36f..f4435dd24 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -4,3 +4,4 @@ * [Configuring a Profile](userguides/profile.md) * [Integerating with a SIEM](userguides/siemexample.md) * [Manage detection list users](userguides/detectionlists.md) +* [Manage legal hold users](userguides/legalhold.md) diff --git a/docs/userguides/legalhold.md b/docs/userguides/legalhold.md new file mode 100644 index 000000000..0547beff5 --- /dev/null +++ b/docs/userguides/legalhold.md @@ -0,0 +1,96 @@ +# Manage legal hold custodians + +Once you [create a legal hold matter in the Code42 console](https://support.code42.com/Administrator/Cloud/Configuring/Create_a_legal_hold_matter#Step_1:_Create_a_matter), you can use the Code42 CLI to add or release custodians from the matter. + +Use the `legal-hold` commands to manage legal hold custodians. + - To see a list of all the users currently in your organization, you can export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Users_reference#Action_menu). + - To view a list of legal hold matters for your organization, including the matter ID, use the following command: + `code42 legal-hold list` + - To see a list of all the custodians currently associated with a legal hold matter, enter `code42 legal-hold show `. + + +## Get CSV template + +To add multiple custodians to a legal hold matter: + +1. Generate a CSV template. Below is an example command that generates a template to use when bulk adding custodians to legal hold matter. Once generated, the CSV file is saved to your current working directory. + `code42 legal-hold bulk generate-template add` + + To generate a template to use when bulk removing custodians from a legal hold matter: + + `code42 legal-hold bulk generate-template remove` + + The CSV templates for `add` and `remove` have the same columns, but the commands generate different default filenames. + +2. Use the CSV template to enter the matter ID(s) and Code42 usernames for the custodians you want to add to the matters. +To get the ID for a matter, enter `code42 legal-hold list`. +3. Save the CSV file. + +## Add custodians to a legal hold matter + +You can add one or more custodians to a legal hold matter using the Code42 CLI. + +### Add multiple custodians +Once you have entered the matter ID and user information in the CSV file, use the `bulk add-user` command with the CSV file path to add multiple custodians at once. For example: + +`code42 legal-hold bulk add-user /Users/admin/add_users_to_legal_hold.csv` + +### Add a single custodian + +To add a single custodian to a legal hold matter, use the following command as an example: + +`code42 legal-hold add-user --matter-id 123456789123456789 --username user@example.com` + +#### Options + + - `--matter-id` (required): The identification number of the legal hold matter. To get the ID for a matter, run the command `code42 legal-hold list`. + - `--username` (required): The Code42 username of the custodian to add to the matter. + - `--profile` (optional): The profile to use to execute the command. If not specified, the default profile is used. + +## Release custodians +You can [release one or more custodians](https://support.code42.com/Administrator/Cloud/Configuring/Create_a_legal_hold_matter#Release_or_reactivate_custodians) from a legal hold matter using the Code42 CLI. + +### Release multiple custodians + +To release multiple custodians at once: + +1. Enter the matter ID(s) and Code42 usernames to the [CSV file template you generated](#get-csv-template). +2. Save the file to your current working directory. +3. Use the `bulk remove-user` command with the file path of the CSV you created. For example: + `code42 legal-hold bulk remove-user /Users/admin/remove_users_from_legal_hold.csv` + +### Release a single custodian + +Use `remove-user` to remove a single custodian. For example: + +`code42 legal-hold remove-user --matter-id 123456789123456789 --username user@example.com` + +Options are the same as `add-user` shown above. + +## View matters and custodians + +You can use the Code42 CLI to get a list of all the [legal hold matters](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Legal_Hold_reference#All_Matters) for your organization, or get full details for a matter. + +### List legal hold matters + +To view a list of legal hold matters for your organization, use the following command: + +`code42 legal-hold list` + +This command produces the matter ID, name, description, creator, and creation date for the legal hold matters. + +### View matter details + +To view active custodians for a legal hold matter, enter `code42 legal-hold show` with the matter ID, for example: + +`code42 legal-hold show 123456789123456789` + +To view active custodians for a legal hold matter, as well as the details of the preservation policy, enter + +`code42 legal-hold show --include-policy` + +To view all custodians (including inactive) for a legal hold matter, enter + +`code42 legal-hold show --include-inactive` + +Learn more about the [Legal Hold](../commands/legalhold.md) commands. From 7685c05cf22e8e6890456dad5afe8f338d4f43e7 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Wed, 29 Jul 2020 11:32:15 -0500 Subject: [PATCH 106/349] Update legalhold.md (#129) --- docs/userguides/legalhold.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/userguides/legalhold.md b/docs/userguides/legalhold.md index 0547beff5..a3d762be1 100644 --- a/docs/userguides/legalhold.md +++ b/docs/userguides/legalhold.md @@ -16,7 +16,7 @@ To add multiple custodians to a legal hold matter: 1. Generate a CSV template. Below is an example command that generates a template to use when bulk adding custodians to legal hold matter. Once generated, the CSV file is saved to your current working directory. `code42 legal-hold bulk generate-template add` - To generate a template to use when bulk removing custodians from a legal hold matter: + To generate a template to use when bulk releasing custodians from a legal hold matter: `code42 legal-hold bulk generate-template remove` @@ -61,7 +61,7 @@ To release multiple custodians at once: ### Release a single custodian -Use `remove-user` to remove a single custodian. For example: +Use `remove-user` to release a single custodian. For example: `code42 legal-hold remove-user --matter-id 123456789123456789 --username user@example.com` From a63c4f1dd58c67e800745b0f19b55c3948ddeabc Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Wed, 29 Jul 2020 14:44:21 -0500 Subject: [PATCH 107/349] Update guides.md (#131) --- docs/guides.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guides.md b/docs/guides.md index f4435dd24..79100952f 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -1,7 +1,7 @@ # User Guides -* [Getting Started](userguides/gettingstarted.md) -* [Configuring a Profile](userguides/profile.md) -* [Integerating with a SIEM](userguides/siemexample.md) +* [Get started with the Code42 command-line interface (CLI)](userguides/gettingstarted.md) +* [Configure a profile](userguides/profile.md) +* [Ingest file events into a SIEM](userguides/siemexample.md) * [Manage detection list users](userguides/detectionlists.md) * [Manage legal hold users](userguides/legalhold.md) From 6b1a7daaa29ded34d89759e25e3982c6afcdfca8 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Fri, 31 Jul 2020 19:45:49 +0530 Subject: [PATCH 108/349] Feature/output formats (#128) * Added support for multiple output formats, ascii-table, csv, formatted-json, json * Added tests * Reverts dependency of texttable module, uses existing table logic instead Refactored find_format_method to return tabular format output instead of printing it. * Added format options to command secuirty-data list and legal-hold list and show subcommands * Set newline to default value * Filter json results * refactor- proper naming convention * Added changelog * Fix failing test * Added test for commands with -f option * imporvise docs * Fix 3.5 test failure --- CHANGELOG.md | 5 + src/code42cli/cmds/alert_rules.py | 10 +- src/code42cli/cmds/enums.py | 8 ++ src/code42cli/cmds/legal_hold.py | 19 ++-- src/code42cli/cmds/securitydata.py | 18 +++- src/code42cli/output_formats.py | 59 +++++++++++ src/code42cli/util.py | 9 +- tests/cmds/test_alert_rules.py | 29 ++++++ tests/cmds/test_legal_hold.py | 57 +++++++++++ tests/cmds/test_securitydata.py | 34 ++++++ tests/test_output_formats.py | 159 +++++++++++++++++++++++++++++ 11 files changed, 385 insertions(+), 22 deletions(-) create mode 100644 src/code42cli/cmds/enums.py create mode 100644 src/code42cli/output_formats.py create mode 100644 tests/test_output_formats.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 372ba3cb4..27dd1139f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `--password` option added to `profile create` and `profile update` commands, enabling creating profiles while bypassing the interactive password prompt. - Profiles can now save multiple alert and file event checkpoints. The name of the checkpoint to be used for a given query should be passed to `-c` (`--use-checkpoint`). - `-y/--assume-yes` option added to `profile delete` and `profile delete-all` commands to not require interactive prompt. +- Below subcommands accept argument `--format/-f` to display result in formats `csv`, `table`, `json`, `formatted-json`: + - `code42 alert-rules list` + - `code42 legal-hold list` + - `code42 legal-hold show` + - `code42 security-data saved-search list` ### Removed diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index a6ea67407..60fa42eca 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -14,8 +14,7 @@ from code42cli.file_readers import read_csv_arg from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.util import find_format_width -from code42cli.util import format_to_table +from code42cli.output_formats import format_option class AlertRuleTypes: @@ -75,13 +74,14 @@ def remove_user(state, rule_id, username): @alert_rules.command("list") +@format_option @sdk_options() -def list_alert_rules(state): +def list_alert_rules(state, format=None): """Fetch existing alert rules.""" selected_rules = _get_all_rules_metadata(state.sdk) if selected_rules: - rows, column_size = find_format_width(selected_rules, _HEADER_KEYS_MAP) - format_to_table(rows, column_size) + formatted_output = format(selected_rules, _HEADER_KEYS_MAP) + echo(formatted_output) @alert_rules.command() diff --git a/src/code42cli/cmds/enums.py b/src/code42cli/cmds/enums.py new file mode 100644 index 000000000..277eea817 --- /dev/null +++ b/src/code42cli/cmds/enums.py @@ -0,0 +1,8 @@ +class OutputFormat: + TABLE = "TABLE" + CSV = "CSV" + JSON = "JSON" + RAW = "RAW-JSON" + + def __iter__(self): + return iter([self.TABLE, self.CSV, self.JSON, self.RAW]) diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index f039b44fa..350448ab0 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -17,9 +17,9 @@ from code42cli.file_readers import read_csv_arg from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.util import find_format_width +from code42cli.output_formats import format_option from code42cli.util import format_string_list_to_columns -from code42cli.util import format_to_table + _MATTER_KEYS_MAP = OrderedDict() _MATTER_KEYS_MAP["legalHoldUid"] = "Matter ID" @@ -71,21 +71,23 @@ def remove_user(state, matter_id, username): @legal_hold.command("list") +@format_option @sdk_options() -def _list(state): +def _list(state, format=None): """Fetch existing legal hold matters.""" matters = _get_all_active_matters(state.sdk) if matters: - rows, column_size = find_format_width(matters, _MATTER_KEYS_MAP) - format_to_table(rows, column_size) + output = format(matters, _MATTER_KEYS_MAP) + echo(output) @legal_hold.command() @click.argument("matter-id") @click.option("--include-inactive", is_flag=True) @click.option("--include-policy", is_flag=True) +@format_option @sdk_options() -def show(state, matter_id, include_inactive=False, include_policy=False): +def show(state, matter_id, include_inactive=False, include_policy=False, format=None): """Display details of a given legal hold matter.""" matter = _check_matter_is_accessible(state.sdk, matter_id) matter["creator_username"] = matter["creator"]["username"] @@ -103,10 +105,9 @@ def show(state, matter_id, include_inactive=False, include_policy=False): member["user"]["username"] for member in memberships if not member["active"] ] - rows, column_size = find_format_width([matter], _MATTER_KEYS_MAP) + output = format([matter], _MATTER_KEYS_MAP) + echo(output) - echo("") - format_to_table(rows, column_size) _print_matter_members(active_usernames, member_type="active") if include_inactive: diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index f38913f8b..2565df756 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -1,3 +1,4 @@ +from _collections import OrderedDict from pprint import pformat import click @@ -15,11 +16,15 @@ from code42cli.options import incompatible_with from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.util import find_format_width -from code42cli.util import format_to_table +from code42cli.output_formats import format_option as format_output + logger = get_main_cli_logger() +_HEADER_KEYS_MAP = OrderedDict() +_HEADER_KEYS_MAP["name"] = "Name" +_HEADER_KEYS_MAP["id"] = "Id" + search_options = searchopt.create_search_options("file events") format_option = click.option( @@ -210,12 +215,15 @@ def saved_search(state): @saved_search.command("list") +@format_output @sdk_options() -def _list(state): +def _list(state, format=None): """List available saved searches.""" response = state.sdk.securitydata.savedsearches.get() - header = {"name": "Name", "id": "Id"} - format_to_table(*find_format_width(response["searches"], header)) + result = response["searches"] + if result: + output = format(result, _HEADER_KEYS_MAP) + echo(output) @saved_search.command() diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py new file mode 100644 index 000000000..e7311e78e --- /dev/null +++ b/src/code42cli/output_formats.py @@ -0,0 +1,59 @@ +import json + +import click + +from code42cli.cmds.enums import OutputFormat +from code42cli.util import find_format_width +from code42cli.util import format_to_table + + +def output_format(_, __, value): + if value is not None: + if value == OutputFormat.CSV: + return to_csv + if value == OutputFormat.RAW: + return to_json + if value == OutputFormat.TABLE: + return to_table + if value == OutputFormat.JSON: + return to_formatted_json + # default option + return to_table + + +format_option = click.option( + "-f", + "--format", + type=click.Choice(OutputFormat(), case_sensitive=False), + help="The output format of the result. Defaults to table format.", + callback=output_format, +) + + +def to_csv(output, header): + columns = ",".join(header.values()) + + lines = [] + lines.append(columns) + for row in output: + items = [str(row[key]) for key in header.keys()] + line = ",".join(items) + lines.append(line) + return "\n".join(lines) + + +def to_table(output, header): + rows, column_size = find_format_width(output, header) + return format_to_table(rows, column_size) + + +def _filter(output, header): + return [{header[key]: row[key] for key in header.keys()} for row in output] + + +def to_json(output, header=None): + return json.dumps(_filter(output, header)) + + +def to_formatted_json(output, header=None): + return json.dumps(_filter(output, header), indent=4) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index b600cd805..4e280669c 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -68,11 +68,14 @@ def find_format_width(record, header): def format_to_table(rows, column_size): - """Prints result in left justified format in a tabular form.""" + """Formats given rows into a string of left justified table.""" + lines = [] for row in rows: + line = "" for key in row.keys(): - echo(str(row[key]).ljust(column_size[key] + _PADDING_SIZE), nl=False) - echo("") + line += str(row[key]).ljust(column_size[key] + _PADDING_SIZE) + lines.append(line) + return "\n".join(lines) def format_string_list_to_columns(string_list, max_width=None): diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index d6813bad2..26a3e672c 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -25,6 +25,19 @@ ] } +TEST_RULE_RESPONSE = { + "ruleMetadata": [ + { + "observerRuleId": TEST_RULE_ID, + "type": "FED_FILE_TYPE_MISMATCH", + "isEnabled": True, + "ruleSource": "NOTVALID", + "name": "Test", + "severity": "high", + } + ] +} + TEST_USER_RULE_RESPONSE = { "ruleMetadata": [ { @@ -275,3 +288,19 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): cli, ["alert-rules", "bulk", "add", "test_remove.csv"], obj=cli_state ) assert bulk_processor.call_args[0][1] == [{"rule_id": "test", "username": "value"}] + + +def test_list_cmd_prints_no_rules_found_when_f_is_passed_and_response_is_empty( + runner, cli_state +): + cli_state.sdk.alerts.rules.get_all.return_value = [TEST_EMPTY_RULE_RESPONSE] + result = runner.invoke(cli, ["alert-rules", "list", "-f", "csv"], obj=cli_state) + assert cli_state.sdk.alerts.rules.get_all.call_count == 1 + assert "No alert rules found" in result.output + + +def test_list_cmd_formats_to_csv_when_format_is_passed(runner, cli_state): + cli_state.sdk.alerts.rules.get_all.return_value = [TEST_RULE_RESPONSE] + result = runner.invoke(cli, ["alert-rules", "list", "-f", "csv"], obj=cli_state) + assert cli_state.sdk.alerts.rules.get_all.call_count == 1 + assert "RuleId,Name,Severity,Type,Source,Enabled" in result.output diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index cd79d88ba..ebfee7a46 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -63,6 +63,30 @@ TEST_PRESERVATION_POLICY_UID ) +TEST_LEGAL_HOLD_LIST = [ + { + "legalHolds": [ + { + "legalHoldUid": "932880202064992021", + "name": "test", + "description": "", + "active": True, + "creationDate": "2019-12-19T20:32:10.763Z", + "lastModified": "2019-12-19T20:32:10.781Z", + "creator": { + "userUid": "921286907298179098", + "username": "test@test.test", + "email": "test@test.test", + }, + "holdPolicyUid": "901109555892625150", + "creator_username": "test@test.test", + }, + ], + } +] + +TEST_LEGAL_HOLD_EMPTY_LIST = [{"legalHolds": []}] + @pytest.fixture def preservation_policy_response(mocker): @@ -371,3 +395,36 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): assert bulk_processor.call_args[0][1] == [ {"matter_id": "test", "username": "value"} ] + + +def test_list_with_format_option_returns_expected_format(runner, cli_state): + cli_state.sdk.legalhold.get_all_matters.return_value = TEST_LEGAL_HOLD_LIST + + result = runner.invoke(cli, ["legal-hold", "list", "-f", "csv"], obj=cli_state) + assert "Matter ID,Name,Description,Creator,Creation Date" in result.output + assert "932880202064992021" in result.output + + +def test_list_with_format_option_returns_no_response_when_response_is_empty( + runner, cli_state +): + cli_state.sdk.legalhold.get_all_matters.return_value = TEST_LEGAL_HOLD_EMPTY_LIST + result = runner.invoke(cli, ["legal-hold", "list", "-f", "csv"], obj=cli_state) + assert "Matter ID,Name,Description,Creator,Creation Date" not in result.output + + +def test_show_with_format_option_returns_expected_format( + runner, cli_state, check_matter_accessible_success, get_user_id_success +): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + ) + result = runner.invoke( + cli, ["legal-hold", "show", TEST_MATTER_ID, "-f", "csv"], obj=cli_state + ) + + assert "Matter ID,Name,Description,Creator,Creation Date" in result.output + assert ( + "88888,Test_Matter,,legal_admin@example.com,2020-01-01T00:00:00.000-06:00" + in result.output + ) diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 2c681745a..34a92ab56 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -19,6 +19,19 @@ CURSOR_TIMESTAMP = 1579500000.0 +TEST_LIST_RESPONSE = { + "searches": [ + { + "id": "a083f08d-8f33-4cbd-81c4-8d1820b61185", + "name": "test-events", + "notes": "py42 is here", + }, + ] +} + +TEST_EMPTY_LIST_RESPONSE = {"searches": []} + + @pytest.fixture def file_event_extractor(mocker): mock = mocker.patch( @@ -691,3 +704,24 @@ def test_search_with_or_query_flag_produces_expected_query(runner, cli_state): str(cli_state.sdk.securitydata.search_file_events.call_args[0][0]) ) assert actual_query == expected_query + + +def test_saved_search_list_with_format_option_returns_csv_formatted_response( + runner, cli_state +): + cli_state.sdk.securitydata.savedsearches.get.return_value = TEST_LIST_RESPONSE + result = runner.invoke( + cli, ["security-data", "saved-search", "list", "-f", "csv"], obj=cli_state + ) + assert "Name,Id" in result.output + assert "test-events,a083f08d-8f33-4cbd-81c4-8d1820b61185" in result.output + + +def test_saved_search_list_with_format_option_does_not_return_when_response_is_empty( + runner, cli_state +): + cli_state.sdk.securitydata.savedsearches.get.return_value = TEST_EMPTY_LIST_RESPONSE + result = runner.invoke( + cli, ["security-data", "saved-search", "list", "-f", "csv"], obj=cli_state + ) + assert "Name,Id" not in result.output diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py new file mode 100644 index 000000000..db9f63911 --- /dev/null +++ b/tests/test_output_formats.py @@ -0,0 +1,159 @@ +import json +from collections import OrderedDict + +from code42cli.output_formats import output_format +from code42cli.output_formats import to_csv +from code42cli.output_formats import to_formatted_json +from code42cli.output_formats import to_json +from code42cli.output_formats import to_table + + +TEST_DATA = [ + { + "type$": "RULE_METADATA", + "modifiedBy": "test.user+partners@code42.com", + "modifiedAt": "2020-06-22T16:26:16.3875180Z", + "name": "outside td", + "description": "", + "severity": "HIGH", + "isSystem": False, + "isEnabled": True, + "ruleSource": "Alerting", + "tenantId": "1d71796f-af5b-4231-9d8e-df6434da4663", + "observerRuleId": "d12d54f0-5160-47a8-a48f-7d5fa5b051c5", + "type": "FED_CLOUD_SHARE_PERMISSIONS", + "id": "5157f1df-cb3e-4755-92a2-0f42c7841020", + "createdBy": "test.user+partners@code42.com", + "createdAt": "2020-06-22T16:26:16.3875180Z", + }, + { + "type$": "RULE_METADATA", + "modifiedBy": "testuser@code42.com", + "modifiedAt": "2020-07-16T08:09:44.4345110Z", + "name": "Test different filters", + "description": "Test different filters", + "severity": "MEDIUM", + "isSystem": False, + "isEnabled": True, + "ruleSource": "Alerting", + "tenantId": "1d71796f-af5b-4231-9d8e-df6434da4663", + "observerRuleId": "8b393324-c34c-44ac-9f79-4313601dd859", + "type": "FED_ENDPOINT_EXFILTRATION", + "id": "88354829-0958-4d60-a20d-69a53cf603b6", + "createdBy": "test.user+partners@code42.com", + "createdAt": "2020-05-20T11:56:41.2324240Z", + }, + { + "type$": "RULE_METADATA", + "modifiedBy": "testuser@code42.com", + "modifiedAt": "2020-05-28T16:19:19.5250970Z", + "name": "Test Alerts using CLI", + "description": "spatel", + "severity": "HIGH", + "isSystem": False, + "isEnabled": True, + "ruleSource": "Alerting", + "tenantId": "1d71796f-af5b-4231-9d8e-df6434da4663", + "observerRuleId": "5eabed1d-a406-4dfc-af81-f7485ee09b19", + "type": "FED_ENDPOINT_EXFILTRATION", + "id": "b2cb33e6-6683-4822-be1d-8de5ef87728e", + "createdBy": "testuser@code42.com", + "createdAt": "2020-05-18T11:47:16.6109560Z", + }, +] + +FILTERED_OUTPUT = [ + { + "RuleId": "d12d54f0-5160-47a8-a48f-7d5fa5b051c5", + "Name": "outside td", + "Severity": "HIGH", + "Type": "FED_CLOUD_SHARE_PERMISSIONS", + "Source": "Alerting", + "Enabled": True, + }, + { + "RuleId": "8b393324-c34c-44ac-9f79-4313601dd859", + "Name": "Test different filters", + "Severity": "MEDIUM", + "Type": "FED_ENDPOINT_EXFILTRATION", + "Source": "Alerting", + "Enabled": True, + }, + { + "RuleId": "5eabed1d-a406-4dfc-af81-f7485ee09b19", + "Name": "Test Alerts using CLI", + "Severity": "HIGH", + "Type": "FED_ENDPOINT_EXFILTRATION", + "Source": "Alerting", + "Enabled": True, + }, +] + +TEST_HEADER = OrderedDict() +TEST_HEADER["observerRuleId"] = "RuleId" +TEST_HEADER["name"] = "Name" +TEST_HEADER["severity"] = "Severity" +TEST_HEADER["type"] = "Type" +TEST_HEADER["ruleSource"] = "Source" +TEST_HEADER["isEnabled"] = "Enabled" + + +TABLE_OUTPUT = "\n".join( + [ + """RuleId Name Severity Type Source Enabled """, + """d12d54f0-5160-47a8-a48f-7d5fa5b051c5 outside td HIGH FED_CLOUD_SHARE_PERMISSIONS Alerting True """, + """8b393324-c34c-44ac-9f79-4313601dd859 Test different filters MEDIUM FED_ENDPOINT_EXFILTRATION Alerting True """, + """5eabed1d-a406-4dfc-af81-f7485ee09b19 Test Alerts using CLI HIGH FED_ENDPOINT_EXFILTRATION Alerting True """, + ] +) + +CSV_OUTPUT = """RuleId,Name,Severity,Type,Source,Enabled +d12d54f0-5160-47a8-a48f-7d5fa5b051c5,outside td,HIGH,FED_CLOUD_SHARE_PERMISSIONS,Alerting,True +8b393324-c34c-44ac-9f79-4313601dd859,Test different filters,MEDIUM,FED_ENDPOINT_EXFILTRATION,Alerting,True +5eabed1d-a406-4dfc-af81-f7485ee09b19,Test Alerts using CLI,HIGH,FED_ENDPOINT_EXFILTRATION,Alerting,True""" + + +def test_to_csv_formats_data_to_csv_format(): + formatted_output = to_csv(TEST_DATA, TEST_HEADER) + assert formatted_output == CSV_OUTPUT + + +def test_to_table_formats_data_to_table_format(): + formatted_output = to_table(TEST_DATA, TEST_HEADER) + print(formatted_output) + assert formatted_output == TABLE_OUTPUT + + +def test_to_json(): + formatted_output = to_json(TEST_DATA, TEST_HEADER) + assert formatted_output == json.dumps(FILTERED_OUTPUT) + + +def test_to_formatted_json(): + formatted_output = to_formatted_json(TEST_DATA, TEST_HEADER) + assert formatted_output == json.dumps(FILTERED_OUTPUT, indent=4) + + +def test_output_format_returns_to_formatted_json_function_when_json_format_option_is_passed(): + format_function = output_format(None, None, "JSON") + assert id(format_function) == id(to_formatted_json) + + +def test_output_format_returns_to_json_function_when_raw_json_format_option_is_passed(): + format_function = output_format(None, None, "RAW-JSON") + assert id(format_function) == id(to_json) + + +def test_output_format_returns_to_table_function_when_ascii_table_format_option_is_passed(): + format_function = output_format(None, None, "TABLE") + assert id(format_function) == id(to_table) + + +def test_output_format_returns_to_csv_function_when_csv_format_option_is_passed(): + format_function = output_format(None, None, "CSV") + assert id(format_function) == id(to_csv) + + +def test_output_format_returns_to_table_function_when_no_format_option_is_passed(): + format_function = output_format(None, None, None) + assert id(format_function) == id(to_table) From 6d153ad248760088d2b09c65024b3ea262046f51 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 11 Aug 2020 14:26:04 -0500 Subject: [PATCH 109/349] implement doc feedback (INTEG-1135) (#135) * implement doc feedback * fix test * make path an option on generate-template * update changelog * whitespace --- CHANGELOG.md | 2 ++ src/code42cli/bulk.py | 11 ++++---- src/code42cli/cmds/alert_rules.py | 7 +++-- src/code42cli/cmds/alerts.py | 14 +++++----- src/code42cli/cmds/departing_employee.py | 8 +++--- src/code42cli/cmds/detectionlists/options.py | 2 +- src/code42cli/cmds/high_risk_employee.py | 18 ++++++------- src/code42cli/cmds/legal_hold.py | 27 +++++++++++++------- src/code42cli/cmds/profile.py | 6 ++--- src/code42cli/cmds/search/options.py | 4 +-- src/code42cli/cmds/securitydata.py | 15 +++++------ src/code42cli/options.py | 2 +- tests/cmds/test_high_risk_employee.py | 2 +- 13 files changed, 63 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27dd1139f..29568c65d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - A profile created with the `--disable-ssl-errors` flag will now correctly not verify SSL certs when making requests. A warning message is printed each time the CLI is run with a profile configured this way, as it is not recommended. +- The `path` positional argument for bulk `generate-template` commands is now an option (`--p/-p`). + ### Added - `--or-query` option added to `security-data search` and `alerts search` commands which combines the provided filter arguments into an 'OR' query instead of the default 'AND' query. diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 6c574dc52..38f695858 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -45,16 +45,15 @@ def generate_template_cmd_factory(group_name, commands_dict): @click.command() @click.argument("cmd", type=click.Choice(list(commands_dict))) - @click.argument( - "path", - required=False, + @click.option( + "--path", + "-p", type=click.Path(dir_okay=False, resolve_path=True, writable=True), + help="Write template file to specific file path/name.", ) def generate_template(cmd, path): """\b - Generate the csv template needed for bulk adding/removing users. - - Optional PATH argument can be provided to write to a specific file path/name. + Generate the CSV template needed for bulk adding/removing users. """ columns = commands_dict[cmd] if not path: diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index 60fa42eca..04a3f8d58 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -35,14 +35,13 @@ class AlertRuleTypes: @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def alert_rules(state): - """Manage alert rules.""" + """Manage users associated with alert rules.""" pass rule_id_option = click.option( - "--rule-id", required=True, help="Observer ID of the rule." + "--rule-id", required=True, help="Identification number of the alert rule." ) -username_option = click.option("-u", "--username", required=True) @alert_rules.command() @@ -131,7 +130,7 @@ def handle_row(rule_id, username): @bulk.command( - help="Bulk remove users from alert rules from a csv file. CSV file format: {}".format( + help="Bulk remove users from alert rules using a CSV file. CSV file format: {}".format( ",".join(ALERT_RULES_CSV_HEADERS) ) ) diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 7d3f63b61..0797be8bf 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -18,7 +18,7 @@ "--format", type=click.Choice(enum.AlertOutputFormat()), default=enum.AlertOutputFormat.JSON, - help="The format used for outputting alerts.", + help="The format used for the alerts output.", ) severity_option = click.option( "--severity", @@ -34,7 +34,7 @@ type=click.Choice(enum.AlertState()), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.is_in_filter(f.AlertState), - help="Filter alerts by state. Defaults to returning all states.", + help="Filter alerts by status. Defaults to returning all statuses.", ) actor_option = click.option( "--actor", @@ -42,14 +42,14 @@ cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.is_in_filter(f.Actor), help="Filter alerts by including the given actor(s) who triggered the alert. " - "Args must match actor username exactly.", + "Arguments must match the actor's cloud alias exactly.", ) actor_contains_option = click.option( "--actor-contains", multiple=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.contains_filter(f.Actor), - help="Filter alerts by including actor(s) whose username contains the given string.", + help="Filter alerts by including actor(s) whose cloud alias contains the given string.", ) exclude_actor_option = click.option( "--exclude-actor", @@ -57,14 +57,14 @@ cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.not_in_filter(f.Actor), help="Filter alerts by excluding the given actor(s) who triggered the alert. " - "Args must match actor username exactly.", + "Arguments must match actor's cloud alias exactly.", ) exclude_actor_contains_option = click.option( "--exclude-actor-contains", multiple=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.not_contains_filter(f.Actor), - help="Filter alerts by excluding actor(s) whose username contains the given string.", + help="Filter alerts by excluding actor(s) whose cloud alias contains the given string.", ) rule_name_option = click.option( "--rule-name", @@ -148,7 +148,7 @@ def alerts(state): @click.argument("checkpoint-name") @opt.sdk_options() def clear_checkpoint(state, checkpoint_name): - """Remove the saved alert checkpoint from '--use-checkpoint/-c' mode.""" + """Remove the saved alert checkpoint from `--use-checkpoint/-c` mode.""" _get_alert_cursor_store(state.profile.name).delete(checkpoint_name) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index a084abc94..d38d1a0be 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -18,7 +18,7 @@ @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def departing_employee(state): - """For adding and removing employees from the departing employee detection list.""" + """For adding and removing employees from the departing employees detection list.""" pass @@ -33,7 +33,7 @@ def departing_employee(state): @notes_option @sdk_options() def add(state, username, cloud_alias, departure_date, notes): - """Add a user to the departing-employee detection list.""" + """Add a user to the departing employees detection list.""" if departure_date: departure_date = departure_date.strftime("%Y-%m-%d") _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) @@ -65,7 +65,7 @@ def bulk(state): @bulk.command( name="add", - help="Bulk add users to the departing-employee detection list using a csv file with " + help="Bulk add users to the departing employees detection list using a CSV file with " "format: {}".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @@ -85,7 +85,7 @@ def handle_row(username, cloud_alias, departure_date, notes): @bulk.command( name="remove", - help="Bulk remove users from the departing-employee detection list using a newline separated " + help="Bulk remove users from the departing employees detection list using a line-separated " "file of usernames.", ) @read_flat_file_arg diff --git a/src/code42cli/cmds/detectionlists/options.py b/src/code42cli/cmds/detectionlists/options.py index 252f2df01..00c9a3892 100644 --- a/src/code42cli/cmds/detectionlists/options.py +++ b/src/code42cli/cmds/detectionlists/options.py @@ -7,4 +7,4 @@ "that they use for cloud services such as Google Drive, OneDrive, or Box, " "add and monitor the alias.", ) -notes_option = click.option("--notes", help="Notes about the employee.") +notes_option = click.option("--notes", help="Optional notes about the employee.") diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 5874dd4d7..242ca1712 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -30,7 +30,7 @@ @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def high_risk_employee(state): - """For adding and removing employees from the high risk employee detection list.""" + """For adding and removing employees from the high risk employees detection list.""" pass @@ -41,7 +41,7 @@ def high_risk_employee(state): @username_arg @sdk_options() def add(state, username, cloud_alias, risk_tag, notes): - """Add a user to the high-risk-employee detection list.""" + """Add a user to the high risk employees detection list.""" _add_high_risk_employee(state.sdk, username, cloud_alias, risk_tag, notes) @@ -49,7 +49,7 @@ def add(state, username, cloud_alias, risk_tag, notes): @username_arg @sdk_options() def remove(state, username): - """Remove a user from the high-risk-employee detection list.""" + """Remove a user from the high risk employees detection list.""" _remove_high_risk_employee(state.sdk, username) @@ -74,7 +74,7 @@ def remove_risk_tags(state, username, risk_tag): @high_risk_employee.group(cls=OrderedGroup) @sdk_options(hidden=True) def bulk(state): - """Tools for executing bulk high risk employee actions.""" + """Tools for executing high risk employee actions in bulk.""" pass @@ -95,7 +95,7 @@ def bulk(state): @bulk.command( name="add", - help="Bulk add users to the high-risk-employee detection list using a csv file with " + help="Bulk add users to the high risk employees detection list using a CSV file with " "format: {}".format(",".join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) @@ -115,7 +115,7 @@ def handle_row(username, cloud_alias, risk_tag, notes): @bulk.command( name="remove", - help="Bulk remove users from the high-risk-employee detection list using a newline separated " + help="Bulk remove users from the high risk employees detection list using a line-separated " "file of usernames.", ) @read_flat_file_arg @@ -135,7 +135,7 @@ def handle_row(username): @bulk.command( name="add-risk-tags", - help="Adds risk tags to users in bulk using a csv file with format: {}".format( + help="Adds risk tags to users in bulk using a CSV file with format: {}".format( ",".join(RISK_TAG_CSV_HEADERS) ), ) @@ -154,7 +154,7 @@ def handle_row(username, tag): @bulk.command( name="remove-risk-tags", - help="Removes risk tags from users in bulk using a csv file with format: {}".format( + help="Removes risk tags from users in bulk using a CSV file with format: {}".format( ",".join(RISK_TAG_CSV_HEADERS) ), ) @@ -181,7 +181,7 @@ def _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes): sdk, username, cloud_alias=cloud_alias, risk_tag=risk_tag, notes=notes ) except Py42BadRequestError as err: - try_handle_user_already_added_error(err, username, "high-risk-employee list") + try_handle_user_already_added_error(err, username, "high risk employees list") raise diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 350448ab0..67fee562e 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -32,7 +32,7 @@ @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def legal_hold(state): - """For adding and removing employees to legal hold matters.""" + """For adding and removing custodians from legal hold matters.""" pass @@ -41,14 +41,14 @@ def legal_hold(state): "--matter-id", required=True, type=str, - help="ID of the legal hold matter user will be added to.", + help="Identification number of the legal hold matter the custodian will be added to.", ) user_id_option = click.option( "-u", "--username", required=True, type=str, - help="The username of the user to add to the matter.", + help="The username of the custodian to add to the matter.", ) @@ -57,7 +57,7 @@ def legal_hold(state): @user_id_option @sdk_options() def add_user(state, matter_id, username): - """Add a user to a legal hold matter.""" + """Add a custodian to a legal hold matter.""" _add_user_to_legal_hold(state.sdk, matter_id, username) @@ -66,7 +66,7 @@ def add_user(state, matter_id, username): @user_id_option @sdk_options() def remove_user(state, matter_id, username): - """Remove a user from a legal hold matter.""" + """Release a custodian from a legal hold matter.""" _remove_user_from_legal_hold(state.sdk, matter_id, username) @@ -83,8 +83,17 @@ def _list(state, format=None): @legal_hold.command() @click.argument("matter-id") -@click.option("--include-inactive", is_flag=True) -@click.option("--include-policy", is_flag=True) +@click.option( + "--include-inactive", + is_flag=True, + help="View all custodians associated with the legal hold matter, " + "including inactive custodians.", +) +@click.option( + "--include-policy", + is_flag=True, + help="View details of the preservation policy associated with the legal hold matter.", +) @format_option @sdk_options() def show(state, matter_id, include_inactive=False, include_policy=False, format=None): @@ -137,7 +146,7 @@ def bulk(state): @bulk.command( name="add", - help="Bulk add users to legal hold matters from a csv file. CSV file format: {}".format( + help="Bulk add custodians to legal hold matters using a CSV file. CSV file format: {}".format( ",".join(LEGAL_HOLD_CSV_HEADERS) ), ) @@ -153,7 +162,7 @@ def handle_row(matter_id, username): @bulk.command( - help="Bulk remove users from legal hold matters from a csv file. CSV file format: {}".format( + help="Bulk release custodians from legal hold matters using a CSV file. CSV file format: {}".format( ",".join(LEGAL_HOLD_CSV_HEADERS) ) ) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index d3567b8f7..4dac6a9d6 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -14,7 +14,7 @@ @click.group() def profile(): - """For managing Code42 settings.""" + """For managing Code42 connection settings.""" pass @@ -26,7 +26,7 @@ def profile(): help="The name of the Code42 CLI profile to use when executing this command.", ) server_option = click.option( - "-s", "--server", required=True, help="The url and port of the Code42 server.", + "-s", "--server", required=True, help="The URL you use to sign into Code42.", ) username_option = click.option( @@ -43,7 +43,7 @@ def profile(): "--disable-ssl-errors", is_flag=True, help="For development purposes, do not validate the SSL certificates of Code42 servers. " - "This is not recommended unless it is required.", + "This is not recommended, except for specific scenarios like testing.", ) diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index d22c73051..49eee0f0c 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -145,13 +145,13 @@ def create_search_options(search_term): callback=lambda ctx, param, arg: parse_max_timestamp(arg), cls=AdvancedQueryAndSavedSearchIncompatible, help="The end of the date range in which to look for {}, argument format options are " - "the same as --begin.".format(search_term), + "the same as `--begin`.".format(search_term), ) advanced_query_option = click.option( "--advanced-query", help="\b\nA raw JSON {} query. " "Useful for when the provided query parameters do not satisfy your requirements." - "\nWARNING: Using advanced queries is incompatible with other query-building args.".format( + "\nWARNING: Using advanced queries is incompatible with other query-building arguments.".format( search_term ), callback=validate_advanced_query_is_json, diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 2565df756..c4764adb2 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -48,7 +48,7 @@ multiple=True, callback=searchopt.is_in_filter(f.DeviceUsername), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, - help="Limits events to endpoint events for these users.", + help="Limits events to endpoint events for these Code42 users.", ) actor_option = click.option( "--actor", @@ -77,7 +77,7 @@ multiple=True, callback=searchopt.is_in_filter(f.Source), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, - help="Limits events to only those from one of these sources. Example=Gmail.", + help="Limits events to only those from one of these sources. For example, Gmail, Box, or Endpoint.", ) file_name_option = click.option( "--file-name", @@ -91,22 +91,21 @@ multiple=True, callback=searchopt.is_in_filter(f.FilePath), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, - help="Limits events to file events where the file is located at one of these paths.", + help="Limits events to file events where the file is located at one of these paths. Applies to endpoint file events only.", ) process_owner_option = click.option( "--process-owner", multiple=True, callback=searchopt.is_in_filter(f.ProcessOwner), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, - help="Limits events to exposure events where one of these users owns " - "the process behind the exposure.", + help="Limits exposure events by process owner, as reported by the device’s operating system. Applies only to `Printed` and `Browser or app read` events", ) tab_url_option = click.option( "--tab-url", multiple=True, callback=searchopt.is_in_filter(f.TabURL), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, - help="Limits events to be exposure events with one of these destination tab URLs.", + help="Limits events to be exposure events with one of the specified destination tab URLs.", ) include_non_exposure_option = click.option( "--include-non-exposure", @@ -152,7 +151,7 @@ def file_event_options(f): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def security_data(state): - """Tools for getting security related data, such as file events.""" + """Tools for getting file event data.""" # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_file_event_cursor_store @@ -161,7 +160,7 @@ def security_data(state): @click.argument("checkpoint-name") @sdk_options() def clear_checkpoint(state, checkpoint_name): - """Remove the saved file event checkpoint from '--use-checkpoint/-c' mode.""" + """Remove the saved file event checkpoint from `--use-checkpoint/-c` mode.""" _get_file_event_cursor_store(state.profile.name).delete(checkpoint_name) diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 8556265e2..85e08331a 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -12,7 +12,7 @@ is_flag=True, expose_value=False, callback=lambda ctx, param, value: ctx.obj.set_assume_yes(value), - help='Assume "yes" as answer to all prompts and run non-interactively.', + help='Assume "yes" as the answer to all prompts and run non-interactively.', ) diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index b66fdc755..814829450 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -89,7 +89,7 @@ def test_add_high_risk_employee_when_user_already_added_exits_with_correct_messa ) assert result.exit_code == 1 assert ( - "'{}' is already on the high-risk-employee list.".format(_EMPLOYEE) + "'{}' is already on the high risk employees list.".format(_EMPLOYEE) in result.output ) From 8826da357c4bd548b725c9748f6af0cac704c536 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Thu, 13 Aug 2020 09:05:49 +0530 Subject: [PATCH 110/349] Add date validation for bulk add command in departing employee (#134) * Add date validation for bulk add command in departing employee * Added changelog * Refactor and added negative test data --- CHANGELOG.md | 1 + src/code42cli/cmds/departing_employee.py | 31 +++++++++++++++++++----- tests/cmds/test_departing_employee.py | 2 ++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29568c65d..2ece7bc10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Fixed - Bug where `code42 legal-hold show` would error when terminal was too small. +- Fixed bug in `departing_employee bulk add` command that allowed invalid dates to be passed without validation. ### Changed diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index d38d1a0be..0c4d7330c 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -9,12 +9,16 @@ from code42cli.cmds.detectionlists.options import notes_option from code42cli.cmds.detectionlists.options import username_arg from code42cli.cmds.shared import get_user_id +from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.file_readers import read_flat_file_arg from code42cli.options import OrderedGroup from code42cli.options import sdk_options +DATE_FORMAT = "%Y-%m-%d" + + @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def departing_employee(state): @@ -27,7 +31,7 @@ def departing_employee(state): @click.option( "--departure-date", help="The date the employee is departing. Format: yyyy-MM-dd.", - type=click.DateTime(formats=["%Y-%m-%d"]), + type=click.DateTime(formats=[DATE_FORMAT]), ) @cloud_alias_option @notes_option @@ -35,7 +39,7 @@ def departing_employee(state): def add(state, username, cloud_alias, departure_date, notes): """Add a user to the departing employees detection list.""" if departure_date: - departure_date = departure_date.strftime("%Y-%m-%d") + departure_date = departure_date.strftime(DATE_FORMAT) _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) @@ -70,11 +74,26 @@ def bulk(state): ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @sdk_options() -def bulk_add(state, csv_rows): - sdk = state.sdk - +@click.pass_context +def bulk_add(ctx, state, csv_rows): def handle_row(username, cloud_alias, departure_date, notes): - _add_departing_employee(sdk, username, cloud_alias, departure_date, notes) + if departure_date: + try: + departure_date = click.DateTime(formats=[DATE_FORMAT]).convert( + departure_date, None, None + ) + except click.exceptions.BadParameter: + message = "Invalid date {}, valid date format {}".format( + departure_date, DATE_FORMAT + ) + raise Code42CLIError(message) + ctx.invoke( + add, + username=username, + cloud_alias=cloud_alias, + departure_date=departure_date, + notes=notes, + ) run_bulk_process( handle_row, diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 2dc6593ce..178f012e3 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -122,6 +122,8 @@ def test_add_bulk_users_calls_expected_py42_methods(runner, mocker, cli_state): "test_user,test_alias,2020-01-01,test_note\n", "test_user_2,test_alias_2,2020-02-01,test_note_2\n", "test_user_3,,,\n", + "test_user_3,,2020-30-02,\n", + "test_user_3,,20-02-2020,\n", ] ) runner.invoke( From 1af10f666383a97361e5ae20a9da556b796e51e2 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Mon, 17 Aug 2020 19:52:37 +0530 Subject: [PATCH 111/349] Feature/output formats (#133) * Add format to alerts and security-data search * Added global format options to alerts search command * Added global output formats options to security-data search command * Fields with array response to return data separated by ## * output format in extraction to be dynamic Change --display to --include-all, so as to make it dynamic. Added method in formats to use csv module to format data * Added pagination * Removed unused code * format option raw-json doesn't filter records based on header anymore. * Removed unused code from alerts * Extracted event processing out of extraction module * Added tests and code refactor * Added changelog * Added help text for option include-all * reverts event processing to per row Added method to format table without printing header * updated doc string * Added breaking change note in CHANGELOG * Added pagination * Set default header * refactor, fix test * improve docstring * improvise changelog * Removed unnecessary timestamp recording call * Change order of columns * removed unused method * change pagination implementation * Reverted timestamp recording back * Add test (#136) * Add test * test name * Fix style * Fix tst * Fix format error * Added extraction tests * Fix style check * Print without paging when events are less than 10 * Added tests for dynamic csv Co-authored-by: Juliya Smith --- CHANGELOG.md | 5 ++ src/code42cli/cmds/alerts.py | 46 ++++++++++---- src/code42cli/cmds/search/extraction.py | 40 ++++++++++--- src/code42cli/cmds/securitydata.py | 45 ++++++++++---- src/code42cli/output_formats.py | 46 +++++++++++++- src/code42cli/util.py | 7 ++- tests/test_extraction.py | 79 +++++++++++++++++++++++++ tests/test_output_formats.py | 67 ++++++++++++++++++++- 8 files changed, 299 insertions(+), 36 deletions(-) create mode 100644 tests/test_extraction.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ece7bc10..412db3bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta each time the CLI is run with a profile configured this way, as it is not recommended. - The `path` positional argument for bulk `generate-template` commands is now an option (`--p/-p`). +- Below `search` subcommands accept argument `--format/-f` to display result in formats `csv`, `table`, `json`, `raw-json`: + - Default output format is changed to `table` format from `raw-json`, returns a paginated response. + A predefined properties would be displayed by default, pass `--include-all` to view all non-nested top-level properties. + - `code42 alerts search` + - `code42 security-data search` ### Added diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 0797be8bf..cb8e8b80a 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -1,3 +1,5 @@ +from _collections import OrderedDict + import click import py42.sdk.queries.alerts.filters as f from c42eventextractor.extractors import AlertExtractor @@ -8,18 +10,21 @@ import code42cli.cmds.search.options as searchopt import code42cli.errors as errors import code42cli.options as opt -from code42cli.cmds.search import logger_factory from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.output_formats import extraction_format_option as format_option + + +SEARCH_DEFAULT_HEADER = OrderedDict() +SEARCH_DEFAULT_HEADER["name"] = "RuleName" +SEARCH_DEFAULT_HEADER["actor"] = "Username" +SEARCH_DEFAULT_HEADER["createdAt"] = "ObservedDate" +SEARCH_DEFAULT_HEADER["state"] = "Status" +SEARCH_DEFAULT_HEADER["severity"] = "Severity" +SEARCH_DEFAULT_HEADER["description"] = "Description" search_options = searchopt.create_search_options("alerts") -format_option = click.option( - "-f", - "--format", - type=click.Choice(enum.AlertOutputFormat()), - default=enum.AlertOutputFormat.JSON, - help="The format used for the alerts output.", -) + severity_option = click.option( "--severity", multiple=True, @@ -159,14 +164,33 @@ def clear_checkpoint(state, checkpoint_name): "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible ) @opt.sdk_options() +@click.option( + "--include-all", + default=False, + is_flag=True, + help="Display simple properties of the primary level of the nested response.", +) def search( - cli_state, format, begin, end, advanced_query, use_checkpoint, or_query, **kwargs + cli_state, + format, + begin, + end, + advanced_query, + use_checkpoint, + or_query, + include_all, + **kwargs ): """Search for alerts.""" - output_logger = logger_factory.get_logger_for_stdout(format) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None handlers = ext.create_handlers( - cli_state.sdk, AlertExtractor, output_logger, cursor, use_checkpoint + cli_state.sdk, + AlertExtractor, + cursor, + use_checkpoint, + include_all=include_all, + output_format=format, + output_header=SEARCH_DEFAULT_HEADER, ) extractor = _get_alert_extractor(cli_state.sdk, handlers) extractor.use_or_query = or_query diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index a1d22f40b..4fdd47b3b 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -1,5 +1,6 @@ import json +import click from c42eventextractor import ExtractionHandlers from click import secho from py42.sdk.queries.query_filter import QueryFilterTimestampField @@ -7,6 +8,7 @@ import code42cli.errors as errors from code42cli.date_helper import verify_timestamp_order from code42cli.logger import get_main_cli_logger +from code42cli.output_formats import get_dynamic_header from code42cli.util import warn_interrupt logger = get_main_cli_logger() @@ -28,7 +30,15 @@ def _get_alert_details(sdk, alert_summary_list): return results -def create_handlers(sdk, extractor_class, output_logger, cursor_store, checkpoint_name): +def create_handlers( + sdk, + extractor_class, + cursor_store, + checkpoint_name, + include_all, + output_format, + output_header, +): extractor = extractor_class(sdk, ExtractionHandlers()) handlers = ExtractionHandlers() handlers.TOTAL_EVENTS = 0 @@ -61,12 +71,22 @@ def handle_response(response): events = _get_alert_details(sdk, events) except Exception as ex: handlers.handle_error(ex) - handlers.TOTAL_EVENTS += len(events) - event = None - for event in events: - output_logger.info(event) - if event: - last_event_timestamp = extractor._get_timestamp_from_item(event) + + total_events = len(events) + handlers.TOTAL_EVENTS += total_events + + def paginate(): + yield _process_events(output_format, include_all, events, output_header) + + if len(events) > 10: + click.echo_via_pager(paginate) + else: + for page in paginate(): + click.echo(page) + + # To make sure the extractor records correct timestamp event when `CTRL-C` is pressed. + if total_events: + last_event_timestamp = extractor._get_timestamp_from_item(events[-1]) handlers.record_cursor_position(last_event_timestamp) handlers.handle_response = handle_response @@ -96,3 +116,9 @@ def create_time_range_filter(filter_cls, begin_date=None, end_date=None): elif end_date and not begin_date: return filter_cls.on_or_before(end_date) + + +def _process_events(output_format, include_all, events, output_header): + if include_all: + output_header = get_dynamic_header(events[0]) + return output_format(events, output_header) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index c4764adb2..90da51276 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -10,13 +10,13 @@ import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt import code42cli.errors as errors -from code42cli.cmds.search import logger_factory from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.logger import get_main_cli_logger from code42cli.options import incompatible_with from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.output_formats import format_option as format_output +from code42cli.output_formats import extraction_format_option +from code42cli.output_formats import format_option logger = get_main_cli_logger() @@ -25,15 +25,21 @@ _HEADER_KEYS_MAP["name"] = "Name" _HEADER_KEYS_MAP["id"] = "Id" +SEARCH_DEFAULT_HEADER = OrderedDict() +SEARCH_DEFAULT_HEADER["fileName"] = "FileName" +SEARCH_DEFAULT_HEADER["filePath"] = "FilePath" +SEARCH_DEFAULT_HEADER["eventType"] = "Type" +SEARCH_DEFAULT_HEADER["eventTimestamp"] = "EventTimestamp" +SEARCH_DEFAULT_HEADER["fileCategory"] = "FileCategory" +SEARCH_DEFAULT_HEADER["fileSize"] = "FileSize" +SEARCH_DEFAULT_HEADER["fileOwner"] = "FileOwner" +SEARCH_DEFAULT_HEADER["md5Checksum"] = "MD5Checksum" +SEARCH_DEFAULT_HEADER["sha256Checksum"] = "SHA256Checksum" + + search_options = searchopt.create_search_options("file events") -format_option = click.option( - "-f", - "--format", - type=click.Choice(enum.OutputFormat()), - default=enum.OutputFormat.JSON, - help="The format used for outputting file events.", -) + exposure_type_option = click.option( "-t", "--type", @@ -143,7 +149,7 @@ def file_event_options(f): f = process_owner_option(f) f = tab_url_option(f) f = include_non_exposure_option(f) - f = format_option(f) + f = extraction_format_option(f) f = saved_search_option(f) return f @@ -171,6 +177,12 @@ def clear_checkpoint(state, checkpoint_name): "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible ) @sdk_options() +@click.option( + "--include-all", + default=False, + is_flag=True, + help="Display simple properties of the primary level of the nested response.", +) def search( state, format, @@ -180,15 +192,22 @@ def search( use_checkpoint, saved_search, or_query, + include_all, **kwargs ): """Search for file events.""" - output_logger = logger_factory.get_logger_for_stdout(format) cursor = ( _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None ) + handlers = ext.create_handlers( - state.sdk, FileEventExtractor, output_logger, cursor, use_checkpoint + state.sdk, + FileEventExtractor, + cursor, + use_checkpoint, + include_all, + output_format=format, + output_header=SEARCH_DEFAULT_HEADER, ) extractor = _get_file_event_extractor(state.sdk, handlers) extractor.use_or_query = or_query @@ -214,7 +233,7 @@ def saved_search(state): @saved_search.command("list") -@format_output +@format_option @sdk_options() def _list(state, format=None): """List available saved searches.""" diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index e7311e78e..08b1181dd 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -1,3 +1,5 @@ +import csv +import io import json import click @@ -21,6 +23,20 @@ def output_format(_, __, value): return to_table +def extraction_output_format(_, __, value): + if value is not None: + if value == OutputFormat.CSV: + return to_dynamic_csv + if value == OutputFormat.RAW: + return to_json + if value == OutputFormat.TABLE: + return to_table + if value == OutputFormat.JSON: + return to_formatted_json + # default option + return to_table + + format_option = click.option( "-f", "--format", @@ -30,15 +46,33 @@ def output_format(_, __, value): ) +extraction_format_option = click.option( + "-f", + "--format", + type=click.Choice(OutputFormat(), case_sensitive=False), + help="The output format of the result. Defaults to table format.", + callback=extraction_output_format, +) + + +def to_dynamic_csv(output, header): + string_io = io.StringIO() + writer = csv.DictWriter(string_io, fieldnames=header) + filtered_output = [{key: row[key] for key in header} for row in output] + writer.writeheader() + writer.writerows(filtered_output) + return string_io.getvalue() + + def to_csv(output, header): columns = ",".join(header.values()) - lines = [] lines.append(columns) for row in output: items = [str(row[key]) for key in header.keys()] line = ",".join(items) lines.append(line) + return "\n".join(lines) @@ -52,8 +86,16 @@ def _filter(output, header): def to_json(output, header=None): - return json.dumps(_filter(output, header)) + return json.dumps(output) def to_formatted_json(output, header=None): return json.dumps(_filter(output, header), indent=4) + + +def get_dynamic_header(header_items): + return { + key: key.capitalize() + for key in header_items.keys() + if type(header_items[key]) == str + } diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 4e280669c..b912b9585 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -38,7 +38,7 @@ def get_user_project_path(*subdirs): return result_path -def find_format_width(record, header): +def find_format_width(record, header, include_header=True): """Fetches needed keys/items to be displayed based on header keys. Finds the largest string against each column so as to decide the padding size for the column. @@ -47,11 +47,14 @@ def find_format_width(record, header): record (list of dict), data to be formatted. header (dict), key-value where keys should map to keys of record dict and value is the corresponding column name to be displayed on the cli. + include_header (bool), include header in output, defaults to True. Returns: tuple (list of dict, dict), i.e Filtered records, padding size of columns. """ - rows = [header] + rows = [] + if include_header: + rows.append(header) # Set default max width items to column names max_width_item = dict(header.items()) diff --git a/tests/test_extraction.py b/tests/test_extraction.py new file mode 100644 index 000000000..59b30e98e --- /dev/null +++ b/tests/test_extraction.py @@ -0,0 +1,79 @@ +from c42eventextractor.extractors import BaseExtractor +from py42.response import Py42Response +from requests import Response + +from code42cli.cmds.search.cursor_store import BaseCursorStore +from code42cli.cmds.search.extraction import create_handlers + +key = "events" +header = {"property": "Property"} + + +class TestQuery: + """""" + + pass + + +def search(*args, **kwargs): + pass + + +def test_create_handlers_creates_handlers_that_pass_events_to_output_format( + mocker, sdk, +): + class TestExtractor(BaseExtractor): + def __init__(self, handlers, timestamp_filter): + timestamp_filter._term = "test_term" + super().__init__(key, search, handlers, timestamp_filter, TestQuery) + + def _get_timestamp_from_item(self, item): + pass + + output_format = mocker.MagicMock() + cursor_store = mocker.MagicMock(sepc=BaseCursorStore) + handlers = create_handlers( + sdk, + TestExtractor, + cursor_store, + "chk-name", + False, + output_format, + output_header=header, + ) + http_response = mocker.MagicMock(spec=Response) + events = [{"property": "bar"}] + http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) + py42_response = Py42Response(http_response) + handlers.handle_response(py42_response) + output_format.assert_called_once_with(events, header) + + +def test_include_all_creates_dynamic_header_from_events_to_output_format( + mocker, sdk, +): + class TestExtractor(BaseExtractor): + def __init__(self, handlers, timestamp_filter): + timestamp_filter._term = "test_term" + super().__init__(key, search, handlers, timestamp_filter, TestQuery) + + def _get_timestamp_from_item(self, item): + pass + + output_format = mocker.MagicMock() + cursor_store = mocker.MagicMock(sepc=BaseCursorStore) + handlers = create_handlers( + sdk, + TestExtractor, + cursor_store, + "chk-name", + True, + output_format, + output_header=header, + ) + http_response = mocker.MagicMock(spec=Response) + events = [{"food": "bar"}] + http_response.text = '{{"{0}": [{{"food": "bar"}}]}}'.format(key) + py42_response = Py42Response(http_response) + handlers.handle_response(py42_response) + output_format.assert_called_once_with(events, {"food": "Food"}) diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index db9f63911..cd332cc86 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -1,8 +1,11 @@ import json from collections import OrderedDict +from code42cli.output_formats import extraction_output_format +from code42cli.output_formats import get_dynamic_header from code42cli.output_formats import output_format from code42cli.output_formats import to_csv +from code42cli.output_formats import to_dynamic_csv from code42cli.output_formats import to_formatted_json from code42cli.output_formats import to_json from code42cli.output_formats import to_table @@ -112,6 +115,31 @@ 8b393324-c34c-44ac-9f79-4313601dd859,Test different filters,MEDIUM,FED_ENDPOINT_EXFILTRATION,Alerting,True 5eabed1d-a406-4dfc-af81-f7485ee09b19,Test Alerts using CLI,HIGH,FED_ENDPOINT_EXFILTRATION,Alerting,True""" +DYNAMIC_CSV_OUTPUT = "\r\n".join( + [ + "observerRuleId,name,severity,type,ruleSource,isEnabled", + "d12d54f0-5160-47a8-a48f-7d5fa5b051c5,outside td,HIGH,FED_CLOUD_SHARE_PERMISSIONS,Alerting,True", + "8b393324-c34c-44ac-9f79-4313601dd859,Test different filters,MEDIUM,FED_ENDPOINT_EXFILTRATION,Alerting,True", + "5eabed1d-a406-4dfc-af81-f7485ee09b19,Test Alerts using CLI,HIGH,FED_ENDPOINT_EXFILTRATION,Alerting,True", + "", + ] +) + + +TEST_NESTED_DATA = { + "test": "TEST", + "name": "outside td", + "description": "", + "severity": "HIGH", + "isSystem": False, + "isEnabled": True, + "ruleSource": ["Alerting"], + "tenantId": "1d71796f-af5b-4231-9d8e-df6434da4663", + "observerRuleId": {"test": ["d12d54f0-5160-47a8-a48f-7d5fa5b051c5"]}, + "type": ["FED_CLOUD_SHARE_PERMISSIONS"], + "id": "5157f1df-cb3e-4755-92a2-0f42c7841020", +} + def test_to_csv_formats_data_to_csv_format(): formatted_output = to_csv(TEST_DATA, TEST_HEADER) @@ -126,7 +154,7 @@ def test_to_table_formats_data_to_table_format(): def test_to_json(): formatted_output = to_json(TEST_DATA, TEST_HEADER) - assert formatted_output == json.dumps(FILTERED_OUTPUT) + assert formatted_output == json.dumps(TEST_DATA) def test_to_formatted_json(): @@ -157,3 +185,40 @@ def test_output_format_returns_to_csv_function_when_csv_format_option_is_passed( def test_output_format_returns_to_table_function_when_no_format_option_is_passed(): format_function = output_format(None, None, None) assert id(format_function) == id(to_table) + + +def test_output_format_returns_to_dynamic_csv_function_when_csv_option_is_passed(): + extraction_output_format_function = extraction_output_format(None, None, "CSV") + assert id(extraction_output_format_function) == id(to_dynamic_csv) + + +def test_output_format_returns_to_table_function_when_table_option_is_passed(): + extraction_output_format_function = extraction_output_format(None, None, "TABLE") + assert id(extraction_output_format_function) == id(to_table) + + +def test_extraction_output_format_returns_to_formatted_json_function_when_json__option_is_passed(): + format_function = extraction_output_format(None, None, "JSON") + assert id(format_function) == id(to_formatted_json) + + +def test_extraction_output_format_returns_to_json_function_when_raw_json_format_option_is_passed(): + format_function = extraction_output_format(None, None, "RAW-JSON") + assert id(format_function) == id(to_json) + + +def test_get_format_header_returns_all_keys_only_which_are_not_nested(): + header = get_dynamic_header(TEST_NESTED_DATA) + assert header == { + "test": "Test", + "name": "Name", + "description": "Description", + "severity": "Severity", + "tenantId": "Tenantid", + "id": "Id", + } + + +def test_to_dynamic_csv(): + formatted_output = to_dynamic_csv(TEST_DATA, TEST_HEADER) + assert formatted_output == DYNAMIC_CSV_OUTPUT From 62b52631a6d398d949ad69edad851e8d97df9d65 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 20 Aug 2020 13:32:11 +0000 Subject: [PATCH 112/349] Bugfix/put back cef (#138) --- CONTRIBUTING.md | 2 +- integration/test_alerts.py | 4 +- src/code42cli/cmds/alert_rules.py | 6 +- src/code42cli/cmds/alerts.py | 13 +- src/code42cli/cmds/enums.py | 8 - src/code42cli/cmds/legal_hold.py | 9 +- src/code42cli/cmds/search/enums.py | 16 +- src/code42cli/cmds/search/extraction.py | 32 +- src/code42cli/cmds/search/logger_factory.py | 33 -- src/code42cli/cmds/securitydata.py | 32 +- .../cmds/securitydata_output_formats.py | 100 ++++ src/code42cli/options.py | 9 + src/code42cli/output_formats.py | 74 +-- src/code42cli/util.py | 2 +- tests/cmds/conftest.py | 10 - tests/cmds/search/__init__.py | 0 tests/{ => cmds/search}/test_cursor_store.py | 0 tests/cmds/search/test_enums.py | 8 + tests/{ => cmds/search}/test_extraction.py | 54 +- tests/cmds/test_alert_rules.py | 7 +- tests/cmds/test_alerts.py | 10 +- tests/cmds/test_legal_hold.py | 22 +- tests/cmds/test_securitydata.py | 21 +- .../cmds/test_securitydata_output_formats.py | 542 ++++++++++++++++++ tests/test_logger_factory.py | 38 -- tests/test_output_formats.py | 91 ++- 26 files changed, 845 insertions(+), 298 deletions(-) delete mode 100644 src/code42cli/cmds/enums.py delete mode 100644 src/code42cli/cmds/search/logger_factory.py create mode 100644 src/code42cli/cmds/securitydata_output_formats.py create mode 100644 tests/cmds/search/__init__.py rename tests/{ => cmds/search}/test_cursor_store.py (100%) create mode 100644 tests/cmds/search/test_enums.py rename tests/{ => cmds/search}/test_extraction.py (57%) create mode 100644 tests/cmds/test_securitydata_output_formats.py delete mode 100644 tests/test_logger_factory.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb19452b0..597945b09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -183,6 +183,6 @@ Document all notable consumer-affecting changes in CHANGELOG.md per principles a ## Opening a PR -When you're satisified with your changes, open a PR and fill out the pull request template file. We recommend prefixing the name of your branch and/or PR title with `bugfix`, `chore`, or `feature` to help quickly categorize your change. Your unit tests and other checks will run against all supported python versions when you do this. +When you're satisfied with your changes, open a PR and fill out the pull request template file. We recommend prefixing the name of your branch and/or PR title with `bugfix`, `chore`, or `feature` to help quickly categorize your change. Your unit tests and other checks will run against all supported python versions when you do this. A team member should get in contact with you shortly to help merge your PR to completion and get it ready for a release! diff --git a/integration/test_alerts.py b/integration/test_alerts.py index 88a2ada3b..b81c23a0d 100644 --- a/integration/test_alerts.py +++ b/integration/test_alerts.py @@ -24,9 +24,9 @@ def _validate_field_value(field, value, response): ("{} --state OPEN".format(ALERT_COMMAND), "state", "OPEN"), ("{} --state RESOLVED".format(ALERT_COMMAND), "state", "RESOLVED"), ( - "{} --actor spatel@code42.com".format(ALERT_COMMAND), + "{} --actor user@code42.com".format(ALERT_COMMAND), "actor", - "spatel@code42.com", + "user@code42.com", ), ( "{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index 04a3f8d58..fe5f541ae 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -12,9 +12,10 @@ from code42cli.errors import Code42CLIError from code42cli.errors import InvalidRuleTypeError from code42cli.file_readers import read_csv_arg +from code42cli.options import format_option from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.output_formats import format_option +from code42cli.output_formats import get_output_format_func class AlertRuleTypes: @@ -77,9 +78,10 @@ def remove_user(state, rule_id, username): @sdk_options() def list_alert_rules(state, format=None): """Fetch existing alert rules.""" + format_func = get_output_format_func(format) selected_rules = _get_all_rules_metadata(state.sdk) if selected_rules: - formatted_output = format(selected_rules, _HEADER_KEYS_MAP) + formatted_output = format_func(selected_rules, _HEADER_KEYS_MAP) echo(formatted_output) diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index cb8e8b80a..d41a98362 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -11,8 +11,8 @@ import code42cli.errors as errors import code42cli.options as opt from code42cli.cmds.search.cursor_store import AlertCursorStore -from code42cli.output_formats import extraction_format_option as format_option - +from code42cli.options import format_option +from code42cli.output_formats import get_output_format_func SEARCH_DEFAULT_HEADER = OrderedDict() SEARCH_DEFAULT_HEADER["name"] = "RuleName" @@ -182,15 +182,18 @@ def search( **kwargs ): """Search for alerts.""" + output_header = ext.try_get_default_header( + include_all, SEARCH_DEFAULT_HEADER, format + ) + format_func = get_output_format_func(format) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None handlers = ext.create_handlers( cli_state.sdk, AlertExtractor, cursor, use_checkpoint, - include_all=include_all, - output_format=format, - output_header=SEARCH_DEFAULT_HEADER, + format_func=format_func, + output_header=output_header, ) extractor = _get_alert_extractor(cli_state.sdk, handlers) extractor.use_or_query = or_query diff --git a/src/code42cli/cmds/enums.py b/src/code42cli/cmds/enums.py deleted file mode 100644 index 277eea817..000000000 --- a/src/code42cli/cmds/enums.py +++ /dev/null @@ -1,8 +0,0 @@ -class OutputFormat: - TABLE = "TABLE" - CSV = "CSV" - JSON = "JSON" - RAW = "RAW-JSON" - - def __iter__(self): - return iter([self.TABLE, self.CSV, self.JSON, self.RAW]) diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 67fee562e..694e0d744 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -15,9 +15,10 @@ from code42cli.errors import UserAlreadyAddedError from code42cli.errors import UserNotInLegalHoldError from code42cli.file_readers import read_csv_arg +from code42cli.options import format_option from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.output_formats import format_option +from code42cli.output_formats import get_output_format_func from code42cli.util import format_string_list_to_columns @@ -75,9 +76,10 @@ def remove_user(state, matter_id, username): @sdk_options() def _list(state, format=None): """Fetch existing legal hold matters.""" + format_func = get_output_format_func(format) matters = _get_all_active_matters(state.sdk) if matters: - output = format(matters, _MATTER_KEYS_MAP) + output = format_func(matters, _MATTER_KEYS_MAP) echo(output) @@ -98,6 +100,7 @@ def _list(state, format=None): @sdk_options() def show(state, matter_id, include_inactive=False, include_policy=False, format=None): """Display details of a given legal hold matter.""" + format_func = get_output_format_func(format) matter = _check_matter_is_accessible(state.sdk, matter_id) matter["creator_username"] = matter["creator"]["username"] @@ -114,7 +117,7 @@ def show(state, matter_id, include_inactive=False, include_policy=False, format= member["user"]["username"] for member in memberships if not member["active"] ] - output = format([matter], _MATTER_KEYS_MAP) + output = format_func([matter], _MATTER_KEYS_MAP) echo(output) _print_matter_members(active_usernames, member_type="active") diff --git a/src/code42cli/cmds/search/enums.py b/src/code42cli/cmds/search/enums.py index e0f9c3e8f..4ecbfedc0 100644 --- a/src/code42cli/cmds/search/enums.py +++ b/src/code42cli/cmds/search/enums.py @@ -1,21 +1,13 @@ +from code42cli.output_formats import OutputFormat + IS_CHECKPOINT_KEY = "use_checkpoint" -class OutputFormat: +class FileEventsOutputFormat(OutputFormat): CEF = "CEF" - JSON = "JSON" - RAW = "RAW-JSON" - - def __iter__(self): - return iter([self.CEF, self.JSON, self.RAW]) - - -class AlertOutputFormat: - JSON = "JSON" - RAW = "RAW-JSON" def __iter__(self): - return iter([self.JSON, self.RAW]) + return iter([self.TABLE, self.CSV, self.JSON, self.RAW, self.CEF]) class AlertSeverity: diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index 4fdd47b3b..843b32361 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -8,7 +8,7 @@ import code42cli.errors as errors from code42cli.date_helper import verify_timestamp_order from code42cli.logger import get_main_cli_logger -from code42cli.output_formats import get_dynamic_header +from code42cli.output_formats import OutputFormat from code42cli.util import warn_interrupt logger = get_main_cli_logger() @@ -16,6 +16,20 @@ _ALERT_DETAIL_BATCH_SIZE = 100 +def try_get_default_header(include_all, default_header, output_format): + """Returns appropriate header based on include-all and output format. If returns None, + the CLI format option will figure out the header based on the data keys.""" + output_header = None + + if output_format == OutputFormat.TABLE and not include_all: + output_header = default_header + elif output_format != OutputFormat.TABLE and include_all: + err_text = "--include-all only allowed for non-Table output formats." + logger.log_error(err_text) + raise errors.Code42CLIError(err_text) + return output_header + + def _get_alert_details(sdk, alert_summary_list): alert_ids = [alert["id"] for alert in alert_summary_list] batches = [ @@ -31,13 +45,7 @@ def _get_alert_details(sdk, alert_summary_list): def create_handlers( - sdk, - extractor_class, - cursor_store, - checkpoint_name, - include_all, - output_format, - output_header, + sdk, extractor_class, cursor_store, checkpoint_name, format_func, output_header, ): extractor = extractor_class(sdk, ExtractionHandlers()) handlers = ExtractionHandlers() @@ -76,7 +84,7 @@ def handle_response(response): handlers.TOTAL_EVENTS += total_events def paginate(): - yield _process_events(output_format, include_all, events, output_header) + yield format_func(events, output_header) if len(events) > 10: click.echo_via_pager(paginate) @@ -116,9 +124,3 @@ def create_time_range_filter(filter_cls, begin_date=None, end_date=None): elif end_date and not begin_date: return filter_cls.on_or_before(end_date) - - -def _process_events(output_format, include_all, events, output_header): - if include_all: - output_header = get_dynamic_header(events[0]) - return output_format(events, output_header) diff --git a/src/code42cli/cmds/search/logger_factory.py b/src/code42cli/cmds/search/logger_factory.py deleted file mode 100644 index 5ca007fb9..000000000 --- a/src/code42cli/cmds/search/logger_factory.py +++ /dev/null @@ -1,33 +0,0 @@ -import logging - -from c42eventextractor.logging.formatters import FileEventDictToCEFFormatter -from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter -from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter - -from code42cli.cmds.search.enums import OutputFormat -from code42cli.logger import add_handler_to_logger -from code42cli.logger import get_logger_for_stdout as get_stdout_logger - - -def get_logger_for_stdout(output_format): - """Gets the stdout logger for the given format. - Args: - output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. - """ - formatter = _get_formatter(output_format) - return get_stdout_logger(output_format.lower(), formatter) - - -def _init_logger(logger, handler, output_format): - formatter = _get_formatter(output_format) - logger.setLevel(logging.INFO) - return add_handler_to_logger(logger, handler, formatter) - - -def _get_formatter(output_format): - if output_format == OutputFormat.JSON: - return FileEventDictToJSONFormatter() - elif output_format == OutputFormat.CEF: - return FileEventDictToCEFFormatter() - else: - return FileEventDictToRawJSONFormatter() diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 90da51276..370783ffc 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -11,13 +11,15 @@ import code42cli.cmds.search.options as searchopt import code42cli.errors as errors from code42cli.cmds.search.cursor_store import FileEventCursorStore +from code42cli.cmds.securitydata_output_formats import ( + get_file_events_output_format_func, +) from code42cli.logger import get_main_cli_logger +from code42cli.options import format_option from code42cli.options import incompatible_with from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.output_formats import extraction_format_option -from code42cli.output_formats import format_option - +from code42cli.output_formats import get_output_format_func logger = get_main_cli_logger() @@ -37,6 +39,15 @@ SEARCH_DEFAULT_HEADER["sha256Checksum"] = "SHA256Checksum" +file_events_format_option = click.option( + "-f", + "--format", + type=click.Choice(enum.FileEventsOutputFormat(), case_sensitive=False), + help="The output format of the result. Defaults to table format.", + default=enum.FileEventsOutputFormat.TABLE, +) + + search_options = searchopt.create_search_options("file events") @@ -149,7 +160,7 @@ def file_event_options(f): f = process_owner_option(f) f = tab_url_option(f) f = include_non_exposure_option(f) - f = extraction_format_option(f) + f = file_events_format_option(f) f = saved_search_option(f) return f @@ -196,18 +207,20 @@ def search( **kwargs ): """Search for file events.""" + output_header = ext.try_get_default_header( + include_all, SEARCH_DEFAULT_HEADER, format + ) + format_func = get_file_events_output_format_func(format) cursor = ( _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None ) - handlers = ext.create_handlers( state.sdk, FileEventExtractor, cursor, use_checkpoint, - include_all, - output_format=format, - output_header=SEARCH_DEFAULT_HEADER, + format_func=format_func, + output_header=output_header, ) extractor = _get_file_event_extractor(state.sdk, handlers) extractor.use_or_query = or_query @@ -237,10 +250,11 @@ def saved_search(state): @sdk_options() def _list(state, format=None): """List available saved searches.""" + format_func = get_output_format_func(format) response = state.sdk.securitydata.savedsearches.get() result = response["searches"] if result: - output = format(result, _HEADER_KEYS_MAP) + output = format_func(result, _HEADER_KEYS_MAP) echo(output) diff --git a/src/code42cli/cmds/securitydata_output_formats.py b/src/code42cli/cmds/securitydata_output_formats.py new file mode 100644 index 000000000..7acc2d24a --- /dev/null +++ b/src/code42cli/cmds/securitydata_output_formats.py @@ -0,0 +1,100 @@ +from datetime import datetime + +from c42eventextractor.logging.formatters import CEF_TEMPLATE +from c42eventextractor.logging.formatters import CEF_TIMESTAMP_FIELDS +from c42eventextractor.maps import CEF_CUSTOM_FIELD_NAME_MAP +from c42eventextractor.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP +from c42eventextractor.maps import JSON_TO_CEF_MAP + +import code42cli.cmds.search.enums as enum +from code42cli.output_formats import CEF_DEFAULT_PRODUCT_NAME +from code42cli.output_formats import CEF_DEFAULT_SEVERITY_LEVEL +from code42cli.output_formats import get_output_format_func + + +def get_file_events_output_format_func(value): + if value == enum.FileEventsOutputFormat.CEF: + return to_cef + return get_output_format_func(value) + + +def to_cef(output, header): + return [_convert_event_to_cef(e) for e in output] + + +def _convert_event_to_cef(event): + kvp_list = { + JSON_TO_CEF_MAP[key]: event[key] + for key in event + if key in JSON_TO_CEF_MAP and (event[key] is not None and event[key] != []) + } + + extension = " ".join(_format_cef_kvp(key, kvp_list[key]) for key in kvp_list) + + event_name = event.get("eventType", "UNKNOWN") + signature_id = FILE_EVENT_TO_SIGNATURE_ID_MAP.get(event_name, "C42000") + + cef_log = CEF_TEMPLATE.format( + productName=CEF_DEFAULT_PRODUCT_NAME, + signatureID=signature_id, + eventName=event_name, + severity=CEF_DEFAULT_SEVERITY_LEVEL, + extension=extension, + ) + return cef_log + + +def _format_cef_kvp(cef_field_key, cef_field_value): + if cef_field_key + "Label" in CEF_CUSTOM_FIELD_NAME_MAP: + return _format_custom_cef_kvp(cef_field_key, cef_field_value) + + cef_field_value = _handle_nested_json_fields(cef_field_key, cef_field_value) + if isinstance(cef_field_value, list): + cef_field_value = _convert_list_to_csv(cef_field_value) + elif cef_field_key in CEF_TIMESTAMP_FIELDS: + cef_field_value = _convert_file_event_timestamp_to_cef_timestamp( + cef_field_value + ) + return "{}={}".format(cef_field_key, cef_field_value) + + +def _format_custom_cef_kvp(custom_cef_field_key, custom_cef_field_value): + custom_cef_label_key = "{}Label".format(custom_cef_field_key) + custom_cef_label_value = CEF_CUSTOM_FIELD_NAME_MAP[custom_cef_label_key] + return "{}={} {}={}".format( + custom_cef_field_key, + custom_cef_field_value, + custom_cef_label_key, + custom_cef_label_value, + ) + + +def _handle_nested_json_fields(cef_field_key, cef_field_value): + result = [] + if cef_field_key == "duser": + result = [ + item["cloudUsername"] for item in cef_field_value if type(item) is dict + ] + + return result or cef_field_value + + +def _convert_list_to_csv(_list): + value = ",".join([val for val in _list]) + return value + + +def _convert_file_event_timestamp_to_cef_timestamp(timestamp_value): + try: + _datetime = datetime.strptime(timestamp_value, "%Y-%m-%dT%H:%M:%S.%fZ") + except ValueError: + _datetime = datetime.strptime(timestamp_value, "%Y-%m-%dT%H:%M:%SZ") + value = "{:.0f}".format(_datetime_to_ms_since_epoch(_datetime)) + return value + + +def _datetime_to_ms_since_epoch(_datetime): + epoch = datetime.utcfromtimestamp(0) + total_seconds = (_datetime - epoch).total_seconds() + # total_seconds will be in decimals (millisecond precision) + return total_seconds * 1000 diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 85e08331a..25819974c 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -3,6 +3,7 @@ import click from code42cli.errors import Code42CLIError +from code42cli.output_formats import OutputFormat from code42cli.profile import get_profile from code42cli.sdk_client import create_sdk @@ -15,6 +16,14 @@ help='Assume "yes" as the answer to all prompts and run non-interactively.', ) +format_option = click.option( + "-f", + "--format", + type=click.Choice(OutputFormat(), case_sensitive=False), + help="The output format of the result. Defaults to table format.", + default=OutputFormat.TABLE, +) + class CLIState: def __init__(self): diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 08b1181dd..0f340dd56 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -2,31 +2,29 @@ import io import json -import click - -from code42cli.cmds.enums import OutputFormat from code42cli.util import find_format_width from code42cli.util import format_to_table -def output_format(_, __, value): - if value is not None: - if value == OutputFormat.CSV: - return to_csv - if value == OutputFormat.RAW: - return to_json - if value == OutputFormat.TABLE: - return to_table - if value == OutputFormat.JSON: - return to_formatted_json - # default option - return to_table +CEF_DEFAULT_PRODUCT_NAME = "Advanced Exfiltration Detection" +CEF_DEFAULT_SEVERITY_LEVEL = "5" + +class OutputFormat: + TABLE = "TABLE" + CSV = "CSV" + JSON = "JSON" + RAW = "RAW-JSON" -def extraction_output_format(_, __, value): + def __iter__(self): + return iter([self.TABLE, self.CSV, self.JSON, self.RAW]) + + +def get_output_format_func(value): if value is not None: + value = value.upper() if value == OutputFormat.CSV: - return to_dynamic_csv + return to_csv if value == OutputFormat.RAW: return to_json if value == OutputFormat.TABLE: @@ -37,46 +35,20 @@ def extraction_output_format(_, __, value): return to_table -format_option = click.option( - "-f", - "--format", - type=click.Choice(OutputFormat(), case_sensitive=False), - help="The output format of the result. Defaults to table format.", - callback=output_format, -) - - -extraction_format_option = click.option( - "-f", - "--format", - type=click.Choice(OutputFormat(), case_sensitive=False), - help="The output format of the result. Defaults to table format.", - callback=extraction_output_format, -) - - -def to_dynamic_csv(output, header): +def to_csv(output, header): + if not output: + return string_io = io.StringIO() - writer = csv.DictWriter(string_io, fieldnames=header) - filtered_output = [{key: row[key] for key in header} for row in output] + writer = csv.DictWriter(string_io, fieldnames=output[0].keys()) writer.writeheader() - writer.writerows(filtered_output) + writer.writerows(output) return string_io.getvalue() -def to_csv(output, header): - columns = ",".join(header.values()) - lines = [] - lines.append(columns) - for row in output: - items = [str(row[key]) for key in header.keys()] - line = ",".join(items) - lines.append(line) - - return "\n".join(lines) - - def to_table(output, header): + if not output: + return + header = header or get_dynamic_header(output[0]) rows, column_size = find_format_width(output, header) return format_to_table(rows, column_size) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index b912b9585..417b940b7 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -46,7 +46,7 @@ def find_format_width(record, header, include_header=True): Args: record (list of dict), data to be formatted. header (dict), key-value where keys should map to keys of record dict and - value is the corresponding column name to be displayed on the cli. + value is the corresponding column name to be displayed on the CLI. include_header (bool), include header in output, defaults to True. Returns: diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 9d71b67fe..2bb9d336a 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -9,7 +9,6 @@ from requests import Response from tests.conftest import convert_str_to_date -from code42cli import PRODUCT_NAME from code42cli.logger import CliLogger @@ -35,15 +34,6 @@ def cli_logger(mocker): return mock -@pytest.fixture -def stdout_logger(mocker): - mock = mocker.patch( - "{}.cmds.search.logger_factory.get_logger_for_stdout".format(PRODUCT_NAME) - ) - mock.return_value = mocker.MagicMock() - return mock - - @pytest.fixture def cli_state_with_user(sdk_with_user, cli_state): cli_state.sdk = sdk_with_user diff --git a/tests/cmds/search/__init__.py b/tests/cmds/search/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_cursor_store.py b/tests/cmds/search/test_cursor_store.py similarity index 100% rename from tests/test_cursor_store.py rename to tests/cmds/search/test_cursor_store.py diff --git a/tests/cmds/search/test_enums.py b/tests/cmds/search/test_enums.py new file mode 100644 index 000000000..f304ce583 --- /dev/null +++ b/tests/cmds/search/test_enums.py @@ -0,0 +1,8 @@ +from code42cli.cmds.search.enums import FileEventsOutputFormat + + +def test_security_data_output_format_has_expected_options(): + options = FileEventsOutputFormat() + actual = list(options) + expected = ["CEF", "CSV", "RAW-JSON", "JSON", "TABLE"] + assert set(actual) == set(expected) diff --git a/tests/test_extraction.py b/tests/cmds/search/test_extraction.py similarity index 57% rename from tests/test_extraction.py rename to tests/cmds/search/test_extraction.py index 59b30e98e..7bb33b81a 100644 --- a/tests/test_extraction.py +++ b/tests/cmds/search/test_extraction.py @@ -1,9 +1,13 @@ +import pytest from c42eventextractor.extractors import BaseExtractor from py42.response import Py42Response from requests import Response +from code42cli import errors from code42cli.cmds.search.cursor_store import BaseCursorStore from code42cli.cmds.search.extraction import create_handlers +from code42cli.cmds.search.extraction import try_get_default_header +from code42cli.output_formats import OutputFormat key = "events" header = {"property": "Property"} @@ -19,37 +23,26 @@ def search(*args, **kwargs): pass -def test_create_handlers_creates_handlers_that_pass_events_to_output_format( - mocker, sdk, -): - class TestExtractor(BaseExtractor): - def __init__(self, handlers, timestamp_filter): - timestamp_filter._term = "test_term" - super().__init__(key, search, handlers, timestamp_filter, TestQuery) +def test_try_get_default_header_raises_cli_error_when_using_include_all_with_none_table_format(): + with pytest.raises(errors.Code42CLIError) as err: + try_get_default_header(True, {}, OutputFormat.CSV) - def _get_timestamp_from_item(self, item): - pass + assert str(err.value) == "--include-all only allowed for non-Table output formats." - output_format = mocker.MagicMock() - cursor_store = mocker.MagicMock(sepc=BaseCursorStore) - handlers = create_handlers( - sdk, - TestExtractor, - cursor_store, - "chk-name", - False, - output_format, - output_header=header, - ) - http_response = mocker.MagicMock(spec=Response) - events = [{"property": "bar"}] - http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) - py42_response = Py42Response(http_response) - handlers.handle_response(py42_response) - output_format.assert_called_once_with(events, header) + +def test_try_get_default_header_uses_default_header_when_not_include_all_and_is_table(): + default_header = {"default": "header"} + actual = try_get_default_header(False, default_header, OutputFormat.TABLE) + assert actual is default_header + + +def test_try_get_default_header_returns_none_when_is_table_and_told_to_include_all(): + default_header = {"default": "header"} + actual = try_get_default_header(True, default_header, OutputFormat.TABLE) + assert actual is None -def test_include_all_creates_dynamic_header_from_events_to_output_format( +def test_create_handlers_creates_handlers_that_pass_events_to_output_format( mocker, sdk, ): class TestExtractor(BaseExtractor): @@ -67,13 +60,12 @@ def _get_timestamp_from_item(self, item): TestExtractor, cursor_store, "chk-name", - True, output_format, output_header=header, ) http_response = mocker.MagicMock(spec=Response) - events = [{"food": "bar"}] - http_response.text = '{{"{0}": [{{"food": "bar"}}]}}'.format(key) + events = [{"property": "bar"}] + http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) py42_response = Py42Response(http_response) handlers.handle_response(py42_response) - output_format.assert_called_once_with(events, {"food": "Food"}) + output_format.assert_called_once_with(events, header) diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index 26a3e672c..b2acfdbad 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -303,4 +303,9 @@ def test_list_cmd_formats_to_csv_when_format_is_passed(runner, cli_state): cli_state.sdk.alerts.rules.get_all.return_value = [TEST_RULE_RESPONSE] result = runner.invoke(cli, ["alert-rules", "list", "-f", "csv"], obj=cli_state) assert cli_state.sdk.alerts.rules.get_all.call_count == 1 - assert "RuleId,Name,Severity,Type,Source,Enabled" in result.output + assert "observerRuleId" in result.output + assert "type" in result.output + assert "isEnabled" in result.output + assert "ruleSource" in result.output + assert "name" in result.output + assert "severity" in result.output diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index c9a61a059..8697c562c 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -154,7 +154,6 @@ def alert_extract_func(mocker): def test_search_with_advanced_query_uses_only_the_extract_advanced_method( cli_state, alert_extractor, runner ): - runner.invoke( cli, ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON], @@ -357,7 +356,7 @@ def test_get_alert_details_sorts_results_by_date(sdk): def test_search_with_only_begin_calls_extract_with_expected_filters( - cli_state, alert_extractor, stdout_logger, begin_option, runner + cli_state, alert_extractor, begin_option, runner ): result = runner.invoke( cli, ["alerts", "search", "--begin", ""], obj=cli_state @@ -384,12 +383,7 @@ def test_search_with_use_checkpoint_and_without_begin_and_without_stored_checkpo def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( - cli_state, - alert_extractor, - begin_option, - alert_cursor_without_checkpoint, - stdout_logger, - runner, + cli_state, alert_extractor, begin_option, alert_cursor_without_checkpoint, runner, ): result = runner.invoke( cli, diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index ebfee7a46..eaf573aeb 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -401,7 +401,15 @@ def test_list_with_format_option_returns_expected_format(runner, cli_state): cli_state.sdk.legalhold.get_all_matters.return_value = TEST_LEGAL_HOLD_LIST result = runner.invoke(cli, ["legal-hold", "list", "-f", "csv"], obj=cli_state) - assert "Matter ID,Name,Description,Creator,Creation Date" in result.output + assert "legalHoldUid" in result.output + assert "name" in result.output + assert "description" in result.output + assert "active" in result.output + assert "creationDate" in result.output + assert "lastModified" in result.output + assert "creator" in result.output + assert "holdPolicyUid" in result.output + assert "creator_username" in result.output assert "932880202064992021" in result.output @@ -422,9 +430,9 @@ def test_show_with_format_option_returns_expected_format( result = runner.invoke( cli, ["legal-hold", "show", TEST_MATTER_ID, "-f", "csv"], obj=cli_state ) - - assert "Matter ID,Name,Description,Creator,Creation Date" in result.output - assert ( - "88888,Test_Matter,,legal_admin@example.com,2020-01-01T00:00:00.000-06:00" - in result.output - ) + assert "88888" in result.output + assert "Test_Matter" in result.output + assert "942564422882759874" in result.output + assert "legal_admin@example.com" in result.output + assert "66666" in result.output + assert "legal_admin@example.com" in result.output diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 34a92ab56..d03162ed8 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -14,6 +14,7 @@ from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.main import cli + BEGIN_TIMESTAMP = 1577858400.0 END_TIMESTAMP = 1580450400.0 CURSOR_TIMESTAMP = 1579500000.0 @@ -303,7 +304,7 @@ def test_search_when_end_date_is_before_begin_date_causes_exit(runner, cli_state def test_search_with_only_begin_calls_extract_with_expected_args( - runner, cli_state, file_event_extractor, stdout_logger, begin_option + runner, cli_state, file_event_extractor, begin_option ): result = runner.invoke( cli, ["security-data", "search", "--begin", "1h"], obj=cli_state @@ -335,7 +336,6 @@ def test_search_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_ file_event_extractor, begin_option, file_event_cursor_without_checkpoint, - stdout_logger, ): result = runner.invoke( cli, @@ -350,11 +350,7 @@ def test_search_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_ def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( - runner, - cli_state, - file_event_extractor, - file_event_cursor_with_checkpoint, - stdout_logger, + runner, cli_state, file_event_extractor, file_event_cursor_with_checkpoint, ): result = runner.invoke( cli, @@ -711,10 +707,15 @@ def test_saved_search_list_with_format_option_returns_csv_formatted_response( ): cli_state.sdk.securitydata.savedsearches.get.return_value = TEST_LIST_RESPONSE result = runner.invoke( - cli, ["security-data", "saved-search", "list", "-f", "csv"], obj=cli_state + cli, ["security-data", "saved-search", "list", "-f", "CSV"], obj=cli_state ) - assert "Name,Id" in result.output - assert "test-events,a083f08d-8f33-4cbd-81c4-8d1820b61185" in result.output + assert "id" in result.output + assert "name" in result.output + assert "notes" in result.output + + assert "083f08d-8f33-4cbd-81c4-8d1820b61185" in result.output + assert "test-events" in result.output + assert "py42 is here" in result.output def test_saved_search_list_with_format_option_does_not_return_when_response_is_empty( diff --git a/tests/cmds/test_securitydata_output_formats.py b/tests/cmds/test_securitydata_output_formats.py new file mode 100644 index 000000000..1229ac97c --- /dev/null +++ b/tests/cmds/test_securitydata_output_formats.py @@ -0,0 +1,542 @@ +import json + +import pytest +from c42eventextractor.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP + +from code42cli.cmds.securitydata_output_formats import ( + get_file_events_output_format_func, +) +from code42cli.cmds.securitydata_output_formats import to_cef +from code42cli.output_formats import to_csv +from code42cli.output_formats import to_formatted_json +from code42cli.output_formats import to_json + + +AED_CLOUD_ACTIVITY_EVENT_DICT = json.loads( + """{ + "url": "https://www.example.com", + "syncDestination": "TEST_SYNC_DESTINATION", + "sharedWith": [{"cloudUsername": "example1@example.com"}, {"cloudUsername": "example2@example.com"}], + "cloudDriveId": "TEST_CLOUD_DRIVE_ID", + "actor": "actor@example.com", + "tabUrl": "TEST_TAB_URL", + "windowTitle": "TEST_WINDOW_TITLE" + }""" +) + + +AED_REMOVABLE_MEDIA_EVENT_DICT = json.loads( + """{ + "removableMediaVendor": "TEST_VENDOR_NAME", + "removableMediaName": "TEST_NAME", + "removableMediaSerialNumber": "TEST_SERIAL_NUMBER", + "removableMediaCapacity": 5000000, + "removableMediaBusType": "TEST_BUS_TYPE" + }""" +) + + +AED_EMAIL_EVENT_DICT = json.loads( + """{ + "emailSender": "TEST_EMAIL_SENDER", + "emailRecipients": ["test.recipient1@example.com", "test.recipient2@example.com"] + }""" +) + + +AED_EVENT_DICT = json.loads( + """{ + "eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16", + "eventType": "READ_BY_APP", + "eventTimestamp": "2019-09-09T02:42:23.851Z", + "insertionTimestamp": "2019-09-09T22:47:42.724Z", + "filePath": "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/", + "fileName": "InfoPlist.strings", + "fileType": "FILE", + "fileCategory": "UNCATEGORIZED", + "fileSize": 86, + "fileOwner": "testtesterson", + "md5Checksum": "19b92e63beb08c27ab4489fcfefbbe44", + "sha256Checksum": "2e0677355c37fa18fd20d372c7420b8b34de150c5801910c3bbb1e8e04c727ef", + "createTimestamp": "2012-07-22T02:19:29Z", + "modifyTimestamp": "2012-12-19T03:00:08Z", + "deviceUserName": "test.testerson+testair@code42.com", + "osHostName": "Test's MacBook Air", + "domainName": "192.168.0.3", + "publicIpAddress": "71.34.4.22", + "privateIpAddresses": [ + "fe80:0:0:0:f053:a9bd:973:6c8c%utun1", + "fe80:0:0:0:a254:cb31:8840:9d6b%utun0", + "0:0:0:0:0:0:0:1%lo0", + "192.168.0.3", + "fe80:0:0:0:0:0:0:1%lo0", + "fe80:0:0:0:8c28:1ac9:5745:a6e7%utun3", + "fe80:0:0:0:2e4a:351c:bb9b:2f28%utun2", + "fe80:0:0:0:6df:855c:9436:37f8%utun4", + "fe80:0:0:0:ce:5072:e5f:7155%en0", + "fe80:0:0:0:b867:afff:fefc:1a82%awdl0", + "127.0.0.1" + ], + "deviceUid": "912339407325443353", + "userUid": "912338501981077099", + "actor": null, + "directoryId": [], + "source": "Endpoint", + "url": null, + "shared": null, + "sharedWith": [], + "sharingTypeAdded": [], + "cloudDriveId": null, + "detectionSourceAlias": null, + "fileId": null, + "exposure": [ + "ApplicationRead" + ], + "processOwner": "testtesterson", + "processName": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "removableMediaVendor": null, + "removableMediaName": null, + "removableMediaSerialNumber": null, + "removableMediaCapacity": null, + "removableMediaBusType": null, + "syncDestination": null + }""" +) + + +@pytest.fixture +def mock_file_event_removable_media_event(): + return [AED_REMOVABLE_MEDIA_EVENT_DICT] + + +@pytest.fixture +def mock_file_event_cloud_activity_event(): + return [AED_CLOUD_ACTIVITY_EVENT_DICT] + + +@pytest.fixture +def mock_file_event_email_event(): + return [AED_EMAIL_EVENT_DICT] + + +@pytest.fixture +def mock_file_event(): + return [AED_EVENT_DICT] + + +def test_file_events_output_format_returns_to_dynamic_csv_function_when_csv_option_is_passed(): + extraction_output_format_function = get_file_events_output_format_func("CSV") + assert id(extraction_output_format_function) == id(to_csv) + + +def test_file_events_output_format_returns_to_formatted_json_function_when_json__option_is_passed(): + format_function = get_file_events_output_format_func("JSON") + assert id(format_function) == id(to_formatted_json) + + +def test_file_events_output_format_returns_to_json_function_when_raw_json_format_option_is_passed(): + format_function = get_file_events_output_format_func("RAW-JSON") + assert id(format_function) == id(to_json) + + +def test_file_events_output_format_returns_to_cef_function_when_cef_format_option_is_passed(): + format_function = get_file_events_output_format_func("CEF") + assert id(format_function) == id(to_cef) + + +def test_to_cef_returns_cef_tagged_string(mock_file_event): + cef_out = to_cef(mock_file_event, None) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[0] == "CEF:0" + + +def test_to_cef_uses_correct_vendor_name(mock_file_event): + cef_out = to_cef(mock_file_event, None) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[1] == "Code42" + + +def test_to_cef_uses_correct_default_product_name(mock_file_event): + cef_out = to_cef(mock_file_event, None) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[2] == "Advanced Exfiltration Detection" + + +def test_to_cef_uses_correct_default_severity(mock_file_event): + cef_out = to_cef(mock_file_event, None) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[6] == "5" + + +def test_to_cef_excludes_none_values_from_output(mock_file_event): + cef_out = to_cef(mock_file_event, None) + cef_parts = get_cef_parts(cef_out) + assert "=None " not in cef_parts[-1] + + +def test_to_cef_excludes_empty_values_from_output(mock_file_event): + cef_out = to_cef(mock_file_event, None) + cef_parts = get_cef_parts(cef_out) + assert "= " not in cef_parts[-1] + + +def test_to_cef_excludes_file_event_fields_not_in_cef_map(mock_file_event): + test_value = "definitelyExcludedValue" + mock_file_event[0]["unmappedFieldName"] = test_value + cef_out = to_cef(mock_file_event, None) + cef_parts = get_cef_parts(cef_out) + del mock_file_event[0]["unmappedFieldName"] + assert test_value not in cef_parts[-1] + + +def test_to_cef_includes_os_hostname_if_present(mock_file_event): + expected_field_name = "shost" + expected_value = "Test's MacBook Air" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_public_ip_address_if_present(mock_file_event): + expected_field_name = "src" + expected_value = "71.34.4.22" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_user_uid_if_present(mock_file_event): + expected_field_name = "suid" + expected_value = "912338501981077099" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_device_username_if_present(mock_file_event): + expected_field_name = "suser" + expected_value = "test.testerson+testair@code42.com" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_capacity_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cn1" + expected_value = "5000000" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_capacity_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cn1Label" + expected_value = "Code42AEDRemovableMediaCapacity" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_bus_type_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs1" + expected_value = "TEST_BUS_TYPE" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_bus_type_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs1Label" + expected_value = "Code42AEDRemovableMediaBusType" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_vendor_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs2" + expected_value = "TEST_VENDOR_NAME" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_vendor_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs2Label" + expected_value = "Code42AEDRemovableMediaVendor" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_name_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs3" + expected_value = "TEST_NAME" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_name_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs3Label" + expected_value = "Code42AEDRemovableMediaName" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_serial_number_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs4" + expected_value = "TEST_SERIAL_NUMBER" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_serial_number_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs4Label" + expected_value = "Code42AEDRemovableMediaSerialNumber" + cef_out = to_cef(mock_file_event_removable_media_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_actor_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "suser" + expected_value = "actor@example.com" + cef_out = to_cef(mock_file_event_cloud_activity_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_sync_destination_if_present( + mock_file_event_cloud_activity_event, +): + expected_field_name = "destinationServiceName" + expected_value = "TEST_SYNC_DESTINATION" + cef_out = to_cef(mock_file_event_cloud_activity_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_event_timestamp_if_present(mock_file_event): + expected_field_name = "end" + expected_value = "1567996943851" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_create_timestamp_if_present(mock_file_event): + expected_field_name = "fileCreateTime" + expected_value = "1342923569000" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_md5_checksum_if_present(mock_file_event): + expected_field_name = "fileHash" + expected_value = "19b92e63beb08c27ab4489fcfefbbe44" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_modify_timestamp_if_present(mock_file_event): + expected_field_name = "fileModificationTime" + expected_value = "1355886008000" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_file_path_if_present(mock_file_event): + expected_field_name = "filePath" + expected_value = "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_file_name_if_present(mock_file_event): + expected_field_name = "fname" + expected_value = "InfoPlist.strings" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_file_size_if_present(mock_file_event): + expected_field_name = "fsize" + expected_value = "86" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_file_category_if_present(mock_file_event): + expected_field_name = "fileType" + expected_value = "UNCATEGORIZED" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_exposure_if_present(mock_file_event): + expected_field_name = "reason" + expected_value = "ApplicationRead" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_url_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "filePath" + expected_value = "https://www.example.com" + cef_out = to_cef(mock_file_event_cloud_activity_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_insertion_timestamp_if_present(mock_file_event): + expected_field_name = "rt" + expected_value = "1568069262724" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_process_name_if_present(mock_file_event): + expected_field_name = "sproc" + expected_value = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_event_id_if_present(mock_file_event): + expected_field_name = "externalId" + expected_value = "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_device_uid_if_present(mock_file_event): + expected_field_name = "deviceExternalId" + expected_value = "912339407325443353" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_domain_name_if_present(mock_file_event): + expected_field_name = "dvchost" + expected_value = "192.168.0.3" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_source_if_present(mock_file_event): + expected_field_name = "sourceServiceName" + expected_value = "Endpoint" + cef_out = to_cef(mock_file_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_cloud_drive_id_if_present( + mock_file_event_cloud_activity_event, +): + expected_field_name = "aid" + expected_value = "TEST_CLOUD_DRIVE_ID" + cef_out = to_cef(mock_file_event_cloud_activity_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_shared_with_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "duser" + expected_value = "example1@example.com,example2@example.com" + cef_out = to_cef(mock_file_event_cloud_activity_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_tab_url_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "request" + expected_value = "TEST_TAB_URL" + cef_out = to_cef(mock_file_event_cloud_activity_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_window_title_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "requestClientApplication" + expected_value = "TEST_WINDOW_TITLE" + cef_out = to_cef(mock_file_event_cloud_activity_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_email_recipients_if_present(mock_file_event_email_event,): + expected_field_name = "duser" + expected_value = "test.recipient1@example.com,test.recipient2@example.com" + cef_out = to_cef(mock_file_event_email_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_email_sender_if_present(mock_file_event_email_event,): + expected_field_name = "suser" + expected_value = "TEST_EMAIL_SENDER" + cef_out = to_cef(mock_file_event_email_event, None) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_created( + mock_file_event, +): + event_type = "CREATED" + mock_file_event[0]["eventType"] = event_type + cef_out = to_cef(mock_file_event, None) + assert event_name_assigned_correct_signature_id(event_type, "C42200", cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_modified( + mock_file_event, +): + event_type = "MODIFIED" + mock_file_event[0]["eventType"] = event_type + cef_out = to_cef(mock_file_event, None) + assert event_name_assigned_correct_signature_id(event_type, "C42201", cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_deleted( + mock_file_event, +): + event_type = "DELETED" + mock_file_event[0]["eventType"] = event_type + cef_out = to_cef(mock_file_event, None) + assert event_name_assigned_correct_signature_id(event_type, "C42202", cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_read_by_app( + mock_file_event, +): + event_type = "READ_BY_APP" + mock_file_event[0]["eventType"] = event_type + cef_out = to_cef(mock_file_event, None) + assert event_name_assigned_correct_signature_id(event_type, "C42203", cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_emailed( + mock_file_event_email_event, +): + event_type = "EMAILED" + mock_file_event_email_event[0]["eventType"] = event_type + cef_out = to_cef(mock_file_event_email_event, None) + assert event_name_assigned_correct_signature_id(event_type, "C42204", cef_out) + + +def get_cef_parts(cef_str): + return cef_str[0].split("|") + + +def key_value_pair_in_cef_extension(field_name, field_value, cef_str): + cef_parts = get_cef_parts(cef_str) + kvp = "{}={}".format(field_name, field_value) + return kvp in cef_parts[-1] + + +def event_name_assigned_correct_signature_id(event_name, signature_id, cef_out): + if event_name in FILE_EVENT_TO_SIGNATURE_ID_MAP: + cef_parts = get_cef_parts(cef_out) + return cef_parts[4] == signature_id and cef_parts[5] == event_name + + return False diff --git a/tests/test_logger_factory.py b/tests/test_logger_factory.py deleted file mode 100644 index 6b51d8634..000000000 --- a/tests/test_logger_factory.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging - -from c42eventextractor.logging.formatters import FileEventDictToCEFFormatter -from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter -from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter - -import code42cli.cmds.search.logger_factory as factory - - -def test_get_logger_for_stdout_has_info_level(): - logger = factory.get_logger_for_stdout("CEF") - assert logger.level == logging.INFO - - -def test_get_logger_for_stdout_when_given_cef_format_uses_cef_formatter(): - logger = factory.get_logger_for_stdout("CEF") - assert type(logger.handlers[0].formatter) == FileEventDictToCEFFormatter - - -def test_get_logger_for_stdout_when_given_json_format_uses_json_formatter(): - logger = factory.get_logger_for_stdout("JSON") - assert type(logger.handlers[0].formatter) == FileEventDictToJSONFormatter - - -def test_get_logger_for_stdout_when_given_raw_json_format_uses_raw_json_formatter(): - logger = factory.get_logger_for_stdout("RAW-JSON") - assert type(logger.handlers[0].formatter) == FileEventDictToRawJSONFormatter - - -def test_get_logger_for_stdout_when_called_twice_has_only_one_handler(): - factory.get_logger_for_stdout("CEF") - logger = factory.get_logger_for_stdout("CEF") - assert len(logger.handlers) == 1 - - -def test_get_logger_for_stdout_uses_stream_handler(): - logger = factory.get_logger_for_stdout("CEF") - assert type(logger.handlers[0]) == logging.StreamHandler diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index cd332cc86..d993655ce 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -1,11 +1,9 @@ import json from collections import OrderedDict -from code42cli.output_formats import extraction_output_format from code42cli.output_formats import get_dynamic_header -from code42cli.output_formats import output_format +from code42cli.output_formats import get_output_format_func from code42cli.output_formats import to_csv -from code42cli.output_formats import to_dynamic_csv from code42cli.output_formats import to_formatted_json from code42cli.output_formats import to_json from code42cli.output_formats import to_table @@ -51,7 +49,7 @@ "modifiedBy": "testuser@code42.com", "modifiedAt": "2020-05-28T16:19:19.5250970Z", "name": "Test Alerts using CLI", - "description": "spatel", + "description": "user", "severity": "HIGH", "isSystem": False, "isEnabled": True, @@ -110,20 +108,12 @@ ] ) -CSV_OUTPUT = """RuleId,Name,Severity,Type,Source,Enabled -d12d54f0-5160-47a8-a48f-7d5fa5b051c5,outside td,HIGH,FED_CLOUD_SHARE_PERMISSIONS,Alerting,True -8b393324-c34c-44ac-9f79-4313601dd859,Test different filters,MEDIUM,FED_ENDPOINT_EXFILTRATION,Alerting,True -5eabed1d-a406-4dfc-af81-f7485ee09b19,Test Alerts using CLI,HIGH,FED_ENDPOINT_EXFILTRATION,Alerting,True""" -DYNAMIC_CSV_OUTPUT = "\r\n".join( - [ - "observerRuleId,name,severity,type,ruleSource,isEnabled", - "d12d54f0-5160-47a8-a48f-7d5fa5b051c5,outside td,HIGH,FED_CLOUD_SHARE_PERMISSIONS,Alerting,True", - "8b393324-c34c-44ac-9f79-4313601dd859,Test different filters,MEDIUM,FED_ENDPOINT_EXFILTRATION,Alerting,True", - "5eabed1d-a406-4dfc-af81-f7485ee09b19,Test Alerts using CLI,HIGH,FED_ENDPOINT_EXFILTRATION,Alerting,True", - "", - ] -) +CSV_OUTPUT = """type$,modifiedBy,modifiedAt,name,description,severity,isSystem,isEnabled,ruleSource,tenantId,observerRuleId,type,id,createdBy,createdAt\r +RULE_METADATA,test.user+partners@code42.com,2020-06-22T16:26:16.3875180Z,outside td,,HIGH,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,d12d54f0-5160-47a8-a48f-7d5fa5b051c5,FED_CLOUD_SHARE_PERMISSIONS,5157f1df-cb3e-4755-92a2-0f42c7841020,test.user+partners@code42.com,2020-06-22T16:26:16.3875180Z\r +RULE_METADATA,testuser@code42.com,2020-07-16T08:09:44.4345110Z,Test different filters,Test different filters,MEDIUM,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,8b393324-c34c-44ac-9f79-4313601dd859,FED_ENDPOINT_EXFILTRATION,88354829-0958-4d60-a20d-69a53cf603b6,test.user+partners@code42.com,2020-05-20T11:56:41.2324240Z\r +RULE_METADATA,testuser@code42.com,2020-05-28T16:19:19.5250970Z,Test Alerts using CLI,user,HIGH,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,5eabed1d-a406-4dfc-af81-f7485ee09b19,FED_ENDPOINT_EXFILTRATION,b2cb33e6-6683-4822-be1d-8de5ef87728e,testuser@code42.com,2020-05-18T11:47:16.6109560Z\r +""" TEST_NESTED_DATA = { @@ -141,17 +131,41 @@ } +def assert_csv_texts_are_equal(actual, expected): + """Have to be careful when testing ordering because of 3.5""" + actual = actual.replace("\r", ",") + actual = actual.replace("\n", ",") + expected = expected.replace("\r", ",") + expected = expected.replace("\n", ",") + actual = set(actual.split(",")) + expected = set(expected.split(",")) + assert actual == expected + + def test_to_csv_formats_data_to_csv_format(): - formatted_output = to_csv(TEST_DATA, TEST_HEADER) - assert formatted_output == CSV_OUTPUT + formatted_output = to_csv(TEST_DATA, None) + assert_csv_texts_are_equal(formatted_output, CSV_OUTPUT) + + +def test_to_csv_when_given_no_output_returns_none(): + assert to_csv(None, None) is None def test_to_table_formats_data_to_table_format(): formatted_output = to_table(TEST_DATA, TEST_HEADER) - print(formatted_output) assert formatted_output == TABLE_OUTPUT +def test_to_table_formats_when_given_no_output_returns_none(): + assert to_table(None, None) is None + + +def test_to_table_when_not_given_header_creates_header_dynamically(): + formatted_output = to_table(TEST_DATA, None) + assert len(formatted_output) > len(TABLE_OUTPUT) + assert "test.user+partners@code42.com" in formatted_output + + def test_to_json(): formatted_output = to_json(TEST_DATA, TEST_HEADER) assert formatted_output == json.dumps(TEST_DATA) @@ -163,51 +177,31 @@ def test_to_formatted_json(): def test_output_format_returns_to_formatted_json_function_when_json_format_option_is_passed(): - format_function = output_format(None, None, "JSON") + format_function = get_output_format_func("JSON") assert id(format_function) == id(to_formatted_json) def test_output_format_returns_to_json_function_when_raw_json_format_option_is_passed(): - format_function = output_format(None, None, "RAW-JSON") + format_function = get_output_format_func("RAW-JSON") assert id(format_function) == id(to_json) def test_output_format_returns_to_table_function_when_ascii_table_format_option_is_passed(): - format_function = output_format(None, None, "TABLE") + format_function = get_output_format_func("TABLE") assert id(format_function) == id(to_table) def test_output_format_returns_to_csv_function_when_csv_format_option_is_passed(): - format_function = output_format(None, None, "CSV") + format_function = get_output_format_func("CSV") assert id(format_function) == id(to_csv) def test_output_format_returns_to_table_function_when_no_format_option_is_passed(): - format_function = output_format(None, None, None) + format_function = get_output_format_func(None) assert id(format_function) == id(to_table) -def test_output_format_returns_to_dynamic_csv_function_when_csv_option_is_passed(): - extraction_output_format_function = extraction_output_format(None, None, "CSV") - assert id(extraction_output_format_function) == id(to_dynamic_csv) - - -def test_output_format_returns_to_table_function_when_table_option_is_passed(): - extraction_output_format_function = extraction_output_format(None, None, "TABLE") - assert id(extraction_output_format_function) == id(to_table) - - -def test_extraction_output_format_returns_to_formatted_json_function_when_json__option_is_passed(): - format_function = extraction_output_format(None, None, "JSON") - assert id(format_function) == id(to_formatted_json) - - -def test_extraction_output_format_returns_to_json_function_when_raw_json_format_option_is_passed(): - format_function = extraction_output_format(None, None, "RAW-JSON") - assert id(format_function) == id(to_json) - - -def test_get_format_header_returns_all_keys_only_which_are_not_nested(): +def test_get_dynamic_header_returns_all_keys_only_which_are_not_nested(): header = get_dynamic_header(TEST_NESTED_DATA) assert header == { "test": "Test", @@ -217,8 +211,3 @@ def test_get_format_header_returns_all_keys_only_which_are_not_nested(): "tenantId": "Tenantid", "id": "Id", } - - -def test_to_dynamic_csv(): - formatted_output = to_dynamic_csv(TEST_DATA, TEST_HEADER) - assert formatted_output == DYNAMIC_CSV_OUTPUT From f66278870f18858e70ce4bcf288ef700892ee27a Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 20 Aug 2020 17:08:57 +0000 Subject: [PATCH 113/349] Add header (#140) --- CONTRIBUTING.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 597945b09..f8a11d674 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ - [Set up your Development environment](#set-up-your-development-environment) - [macOS](#macos) - [Windows/Linux](#windowslinux) +- [Installation](#installation) - [Run a full build](#run-a-full-build) - [Coding Style](#coding-style) - [Style linter](#style-linter) @@ -69,6 +70,8 @@ To leave the virtual environment, simply use: deactivate ``` +## Installation + Next, with your virtual environment activated, install code42cli and its development dependencies. The `-e` option installs code42cli in ["editable mode"](https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs). From 75a6ffe6cfb69493bc54b8079822d76841f3b454 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Thu, 20 Aug 2020 12:09:14 -0500 Subject: [PATCH 114/349] Update gettingstarted.md (#139) --- docs/userguides/gettingstarted.md | 39 ++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index 4dd3006de..8e51104ab 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -69,15 +69,48 @@ python3 -m pip install code42cli --upgrade ## Authentication ```eval_rst -.. important:: the Code42 CLI currently only supports token-based authentication. +.. important:: The Code42 CLI currently only supports token-based authentication. ``` -To use the CLI, you must provide your credentials (basic authentication). The CLI uses keyring when storing passwords. -If you choose not to store your password in the CLI, you must enter it for each command that requires a connection. +To use the CLI, you must provide your credentials (basic authentication). If you choose not to store your password in the CLI, you must enter it for each command that requires a connection. The Code42 CLI currently does **not** support SSO login providers or any other identity providers such as Active Directory or Okta. +### Windows and Mac + +For Windows and Mac systems, the CLI uses Keyring when storing passwords. + +### Red Hat Enterprise Linux + +To use Keyring to store the credentials you enter in the Code42 CLI, enter the following commands before installing. +```bash +yum -y install python-pip python3 dbus-python gnome-keyring libsecret dbus-x11 +pip3 install code42cli +``` +If the following directories do not already exist, create them: +```bash +mkdir -p ~/.cache +mkdir -p ~/.local/share/keyring +``` +In the following commands, replace the example value `\n` with the Keyring password (if the default Keyring already exists). +```bash +eval "$(dbus-launch --sh-syntax)" +eval "$(printf '\n' | gnome-keyring-daemon --unlock)" +eval "$(printf '\n' | /usr/bin/gnome-keyring-daemon --start)" +``` +Close out your D-bus session and GNOME Keyring: +```bash +pkill gnome +pkill dbus +``` +If you do not use Keyring to store your credentials, the Code42 CLI will ask permission to store your credentials in a local flat file with read/write permissions for only the operating system user who set the password. Alternatively, you can enter your password with each command you enter. + +### Ubuntu +If Keyring doesn't support your Ubuntu system, the Code42 CLI will ask permission to store your credentials in a local flat file with read/write permissions for only the operating system user who set the password. Alternatively, you can enter your password with each command you enter. + + + To learn more about authenticating in the CLI, follow the [Configure profile guide](profile.md). ## Troubleshooting and support From 69573e338dd560a8ac1433990cb9ca8e9edf4f1c Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 24 Aug 2020 10:14:51 -0500 Subject: [PATCH 115/349] Update siemexample.md (#141) --- docs/userguides/siemexample.md | 74 +++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index 37d1893b8..49a6e94b9 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -1,11 +1,11 @@ -# Ingest file event data into a SIEM tool +# Ingest file event data or alerts into a SIEM tool -This guide provides instructions on using the CLI to ingest Code42 file event data +This guide provides instructions on using the CLI to ingest Code42 file event data or alerts into a security information and event management (SIEM) tool like LogRhythm, Sumo Logic, or IBM QRadar. ## Considerations -To ingest file events into a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration +To ingest file events or alerts into a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration must be assigned roles that provide the necessary permissions. ## Before you begin @@ -13,45 +13,51 @@ must be assigned roles that provide the necessary permissions. First install and configure the Code42 CLI following the instructions in [Getting Started](gettingstarted.md). -## Run file event queries -You can get security events in either a JSON or CEF format for use by your SIEM tool. You can query the data as a +## Run queries +You can get file events in either a JSON or CEF format for use by your SIEM tool. Alerts data is available in JSON format. You can query the data as a scheduled job or run ad-hoc queries. Learn more about [searching](../commands/securitydata.md) using the CLI. -## Run a query as a scheduled job +### Run a query as a scheduled job Use your favorite scheduling tool, such as cron or Windows Task Scheduler, to run a query on a regular basis. Specify -the profile to use by including `--profile`. An example using `netcat` to forward results to an external syslog server: - +the profile to use by including `--profile`. An example using `netcat` to forward only the new file event data since the previous request to an external syslog server: ```bash code42 security-data search --profile profile1 -c syslog_sender | nc syslog.example.com 514 ``` -As a best practice, use a separate profile when executing a scheduled task. Using separate profiles can help prevent accidental updates to your stored checkpoints, for example, by adding `--use-checkpoint` to adhoc queries. +An example to send to the syslog server only the new alerts that meet the filter criteria since the previous request: +```bash +code42 alerts send-to "https://syslog.example.com:514" -p UDP --profile profile1 --rule-name “Source code exfiltration” --state OPEN -i +``` -This query will send to the syslog server only the new security event data since the previous request. +As a best practice, use a separate profile when executing a scheduled task. Using separate profiles can help prevent accidental updates to your stored checkpoints, for example, by adding `--use-checkpoint` to adhoc queries. -## Run an ad-hoc query +### Run an ad-hoc query Examples of ad-hoc queries you can run are as follows. -Print security data since March 5 for a user in raw JSON format: - +Print file events since March 5 for a user in raw JSON format: ```bash code42 security-data search -f RAW-JSON -b 2020-03-05 --c42-username 'sean.cassidy@example.com' ``` -Print security events since March 5 where a file was synced to a cloud service: +Print file events since March 5 where a file was synced to a cloud service: ```bash code42 security-data search -t CloudStorage -b 2020-03-05 ``` -Write to a text file security events in raw JSON format where a file was read by browser or other app for a user since +Write to a text file the file events in raw JSON format where a file was read by browser or other app for a user since March 5: ```bash code42 security-data search -f RAW-JSON -b 2020-03-05 -t ApplicationRead --c42-username 'sean.cassidy@example.com' > /Users/sangita.maskey/Downloads/c42cli_output.txt ``` -Example output for a single exposure event (in default JSON format): +Print alerts since May 5 where a file's cloud share permissions changed: +```bash +code42 alerts print -b 2020-05-05 --rule-type FedCloudSharePermissions +``` + +Example output for a single file exposure event (in default JSON format): ```json { @@ -88,10 +94,42 @@ Example output for a single exposure event (in default JSON format): "syncDestination": "GoogleBackupAndSync" } ``` +Example output for a single alert (in default JSON format): + +```json +{"type$": "ALERT_DETAILS", +"tenantId": "c4b5e830-824a-40a3-a6d9-345664cfbb33", +"type": "FED_CLOUD_SHARE_PERMISSIONS", +"name": "Cloud Share", +"description": "Alert Rule for data exfiltration via Cloud Share", +"actor": "leland.stewart@example.com", +"target": "N/A", +"severity": "HIGH", +"ruleId": "408eb1ae-587e-421a-9444-f75d5399eacb", +"ruleSource": "Alerting", +"id": "7d936d0d-e783-4b24-817d-f19f625e0965", +"createdAt": "2020-05-22T09:47:33.8863230Z", +"state": "OPEN", +"observations": [{"type$": "OBSERVATION", +"id": "4bc378e6-bfbd-40f0-9572-6ed605ea9f6c", +"observedAt": "2020-05-22T09:40:00.0000000Z", +"type": "FedCloudSharePermissions", +"data": {"type$": "OBSERVED_CLOUD_SHARE_ACTIVITY", +"id": "4bc378e6-bfbd-40f0-9572-6ed605ea9f6c", +"sources": ["GoogleDrive"], +"exposureTypes": ["PublicLinkShare"], +"firstActivityAt": "2020-05-22T09:40:00.0000000Z", +"lastActivityAt": "2020-05-22T09:45:00.0000000Z", +"fileCount": 1, +"totalFileSize": 6025, +"fileCategories": [{"type$": "OBSERVED_FILE_CATEGORY", "category": "Document", "fileCount": 1, "totalFileSize": 6025, "isSignificant": false}], +"files": [{"type$": "OBSERVED_FILE", "eventId": "1hHdK6Qe6hez4vNCtS-UimDf-sbaFd-D7_3_baac33d0-a1d3-4e0a-9957-25632819eda7", "name": "1590140395_Longfellow_Cloud_Arch_Redesign.drawio", "category": "Document", "size": 6025}], +"outsideTrustedDomainsEmailsCount": 0, "outsideTrustedDomainsTotalDomainCount": 0, "outsideTrustedDomainsTotalDomainCountTruncated": false}}]} +``` ## CEF Mapping -The following tables map the data from the Code42 CLI to common event format (CEF). +The following tables map the file event data from the Code42 CLI to common event format (CEF). ### Attribute mapping @@ -177,7 +215,7 @@ to one another. ### Event mapping -See the table below to map exfiltration events to CEF signature IDs. +See the table below to map file events to CEF signature IDs. ```eval_rst From 2ecbd620a8047e36320193c9e5c58e0eedafcef6 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 24 Aug 2020 10:15:08 -0500 Subject: [PATCH 116/349] Update guides.md (#142) --- docs/guides.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides.md b/docs/guides.md index 79100952f..6aa4243e6 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -2,6 +2,6 @@ * [Get started with the Code42 command-line interface (CLI)](userguides/gettingstarted.md) * [Configure a profile](userguides/profile.md) -* [Ingest file events into a SIEM](userguides/siemexample.md) +* [Ingest file events or alerts into a SIEM](userguides/siemexample.md) * [Manage detection list users](userguides/detectionlists.md) * [Manage legal hold users](userguides/legalhold.md) From 4dd9fe1adc860f1123ce06193c81ed2e17cfcf85 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 25 Aug 2020 16:30:02 +0000 Subject: [PATCH 117/349] Bugfix/json output (#143) --- src/code42cli/cmds/alert_rules.py | 8 +- src/code42cli/cmds/alerts.py | 8 +- src/code42cli/cmds/legal_hold.py | 15 +- src/code42cli/cmds/search/extraction.py | 25 +- src/code42cli/cmds/securitydata.py | 22 +- .../cmds/securitydata_output_formats.py | 21 +- src/code42cli/output_formats.py | 80 ++-- src/code42cli/util.py | 27 +- tests/cmds/search/test_extraction.py | 16 +- tests/cmds/test_legal_hold.py | 360 +++++++++++++----- .../cmds/test_securitydata_output_formats.py | 203 +++++----- tests/conftest.py | 20 + tests/test_output_formats.py | 135 +++---- 13 files changed, 579 insertions(+), 361 deletions(-) diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index fe5f541ae..98ce2d8e0 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -15,7 +15,7 @@ from code42cli.options import format_option from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.output_formats import get_output_format_func +from code42cli.output_formats import OutputFormatter class AlertRuleTypes: @@ -78,11 +78,11 @@ def remove_user(state, rule_id, username): @sdk_options() def list_alert_rules(state, format=None): """Fetch existing alert rules.""" - format_func = get_output_format_func(format) + formatter = OutputFormatter(format, _HEADER_KEYS_MAP) selected_rules = _get_all_rules_metadata(state.sdk) if selected_rules: - formatted_output = format_func(selected_rules, _HEADER_KEYS_MAP) - echo(formatted_output) + for output in formatter.get_formatted_output(selected_rules): + echo(output) @alert_rules.command() diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index d41a98362..d64ef0751 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -12,7 +12,7 @@ import code42cli.options as opt from code42cli.cmds.search.cursor_store import AlertCursorStore from code42cli.options import format_option -from code42cli.output_formats import get_output_format_func +from code42cli.output_formats import OutputFormatter SEARCH_DEFAULT_HEADER = OrderedDict() SEARCH_DEFAULT_HEADER["name"] = "RuleName" @@ -185,15 +185,15 @@ def search( output_header = ext.try_get_default_header( include_all, SEARCH_DEFAULT_HEADER, format ) - format_func = get_output_format_func(format) + formatter = OutputFormatter(format, output_header) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None handlers = ext.create_handlers( cli_state.sdk, AlertExtractor, cursor, use_checkpoint, - format_func=format_func, - output_header=output_header, + formatter=formatter, + force_pager=include_all, ) extractor = _get_alert_extractor(cli_state.sdk, handlers) extractor.use_or_query = or_query diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 694e0d744..bdff5cd16 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -18,7 +18,7 @@ from code42cli.options import format_option from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.output_formats import get_output_format_func +from code42cli.output_formats import OutputFormatter from code42cli.util import format_string_list_to_columns @@ -76,11 +76,11 @@ def remove_user(state, matter_id, username): @sdk_options() def _list(state, format=None): """Fetch existing legal hold matters.""" - format_func = get_output_format_func(format) + formatter = OutputFormatter(format, _MATTER_KEYS_MAP) matters = _get_all_active_matters(state.sdk) if matters: - output = format_func(matters, _MATTER_KEYS_MAP) - echo(output) + for output in formatter.get_formatted_output(matters): + echo(output) @legal_hold.command() @@ -100,9 +100,10 @@ def _list(state, format=None): @sdk_options() def show(state, matter_id, include_inactive=False, include_policy=False, format=None): """Display details of a given legal hold matter.""" - format_func = get_output_format_func(format) + formatter = OutputFormatter(format, _MATTER_KEYS_MAP) matter = _check_matter_is_accessible(state.sdk, matter_id) matter["creator_username"] = matter["creator"]["username"] + matter = json.loads(matter.text) # if `active` is None then all matters (whether active or inactive) are returned. True returns # only those that are active. @@ -117,8 +118,8 @@ def show(state, matter_id, include_inactive=False, include_policy=False, format= member["user"]["username"] for member in memberships if not member["active"] ] - output = format_func([matter], _MATTER_KEYS_MAP) - echo(output) + for output in formatter.get_formatted_output([matter]): + echo(output) _print_matter_members(active_usernames, member_type="active") diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index 843b32361..116a52078 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -19,12 +19,9 @@ def try_get_default_header(include_all, default_header, output_format): """Returns appropriate header based on include-all and output format. If returns None, the CLI format option will figure out the header based on the data keys.""" - output_header = None - - if output_format == OutputFormat.TABLE and not include_all: - output_header = default_header - elif output_format != OutputFormat.TABLE and include_all: - err_text = "--include-all only allowed for non-Table output formats." + output_header = None if include_all else default_header + if output_format != OutputFormat.TABLE and include_all: + err_text = "--include-all only allowed for Table output format." logger.log_error(err_text) raise errors.Code42CLIError(err_text) return output_header @@ -45,7 +42,7 @@ def _get_alert_details(sdk, alert_summary_list): def create_handlers( - sdk, extractor_class, cursor_store, checkpoint_name, format_func, output_header, + sdk, extractor_class, cursor_store, checkpoint_name, formatter, force_pager, ): extractor = extractor_class(sdk, ExtractionHandlers()) handlers = ExtractionHandlers() @@ -83,14 +80,16 @@ def handle_response(response): total_events = len(events) handlers.TOTAL_EVENTS += total_events - def paginate(): - yield format_func(events, output_header) + def _format_output(): + return formatter.get_formatted_output(events) - if len(events) > 10: - click.echo_via_pager(paginate) + if len(events) > 10 or force_pager: + click.echo_via_pager(_format_output()) else: - for page in paginate(): - click.echo(page) + for page in _format_output(): + click.echo(page, nl=False) + if formatter.output_format == OutputFormat.TABLE: + click.echo() # To make sure the extractor records correct timestamp event when `CTRL-C` is pressed. if total_events: diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 370783ffc..bcc19e3b7 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -11,15 +11,13 @@ import code42cli.cmds.search.options as searchopt import code42cli.errors as errors from code42cli.cmds.search.cursor_store import FileEventCursorStore -from code42cli.cmds.securitydata_output_formats import ( - get_file_events_output_format_func, -) +from code42cli.cmds.securitydata_output_formats import FileEventsOutputFormatter from code42cli.logger import get_main_cli_logger from code42cli.options import format_option from code42cli.options import incompatible_with from code42cli.options import OrderedGroup from code42cli.options import sdk_options -from code42cli.output_formats import get_output_format_func +from code42cli.output_formats import OutputFormatter logger = get_main_cli_logger() @@ -210,7 +208,7 @@ def search( output_header = ext.try_get_default_header( include_all, SEARCH_DEFAULT_HEADER, format ) - format_func = get_file_events_output_format_func(format) + formatter = FileEventsOutputFormatter(format, output_header) cursor = ( _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None ) @@ -219,8 +217,8 @@ def search( FileEventExtractor, cursor, use_checkpoint, - format_func=format_func, - output_header=output_header, + formatter=formatter, + force_pager=include_all, ) extractor = _get_file_event_extractor(state.sdk, handlers) extractor.use_or_query = or_query @@ -250,12 +248,12 @@ def saved_search(state): @sdk_options() def _list(state, format=None): """List available saved searches.""" - format_func = get_output_format_func(format) + formatter = OutputFormatter(format, _HEADER_KEYS_MAP) response = state.sdk.securitydata.savedsearches.get() - result = response["searches"] - if result: - output = format_func(result, _HEADER_KEYS_MAP) - echo(output) + saved_searches = response["searches"] + if saved_searches: + for output in formatter.get_formatted_output(saved_searches): + echo(output) @saved_search.command() diff --git a/src/code42cli/cmds/securitydata_output_formats.py b/src/code42cli/cmds/securitydata_output_formats.py index 7acc2d24a..a007fd897 100644 --- a/src/code42cli/cmds/securitydata_output_formats.py +++ b/src/code42cli/cmds/securitydata_output_formats.py @@ -9,17 +9,24 @@ import code42cli.cmds.search.enums as enum from code42cli.output_formats import CEF_DEFAULT_PRODUCT_NAME from code42cli.output_formats import CEF_DEFAULT_SEVERITY_LEVEL -from code42cli.output_formats import get_output_format_func +from code42cli.output_formats import OutputFormatter -def get_file_events_output_format_func(value): - if value == enum.FileEventsOutputFormat.CEF: - return to_cef - return get_output_format_func(value) +class FileEventsOutputFormatter(OutputFormatter): + def __init__(self, output_format, header=None): + output_format = ( + output_format.upper() + if output_format + else enum.FileEventsOutputFormat.TABLE + ) + super().__init__(output_format, header) + if output_format == enum.FileEventsOutputFormat.CEF: + self._format_func = to_cef -def to_cef(output, header): - return [_convert_event_to_cef(e) for e in output] +def to_cef(output): + """Output is a single record""" + return "{}\n".format(_convert_event_to_cef(output)) def _convert_event_to_cef(event): diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 0f340dd56..d805d3ebd 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -20,54 +20,66 @@ def __iter__(self): return iter([self.TABLE, self.CSV, self.JSON, self.RAW]) -def get_output_format_func(value): - if value is not None: - value = value.upper() - if value == OutputFormat.CSV: - return to_csv - if value == OutputFormat.RAW: - return to_json - if value == OutputFormat.TABLE: - return to_table - if value == OutputFormat.JSON: - return to_formatted_json - # default option - return to_table - - -def to_csv(output, header): +class OutputFormatter: + def __init__(self, output_format, header=None): + output_format = output_format.upper() if output_format else OutputFormat.TABLE + self.output_format = output_format + self._format_func = to_table + self.header = header + + if output_format == OutputFormat.CSV: + self._format_func = to_csv + elif output_format == OutputFormat.RAW: + self._format_func = to_json + elif output_format == OutputFormat.TABLE: + self._format_func = self._to_table + elif output_format == OutputFormat.JSON: + self._format_func = to_formatted_json + + def _format_output(self, output): + return self._format_func(output) + + def _to_table(self, output): + return to_table(output, self.header) + + def get_formatted_output(self, output): + if self._requires_list_output: + yield self._format_output(output) + else: + for item in output: + yield self._format_output(item) + + @property + def _requires_list_output(self): + return self.output_format in (OutputFormat.TABLE, OutputFormat.CSV) + + +def to_csv(output): + """Output is a list of records""" if not output: return string_io = io.StringIO() - writer = csv.DictWriter(string_io, fieldnames=output[0].keys()) + fieldnames = list({k for d in output for k in d.keys()}) + writer = csv.DictWriter(string_io, fieldnames=fieldnames) writer.writeheader() writer.writerows(output) return string_io.getvalue() def to_table(output, header): + """Output is a list of records""" if not output: return - header = header or get_dynamic_header(output[0]) rows, column_size = find_format_width(output, header) return format_to_table(rows, column_size) -def _filter(output, header): - return [{header[key]: row[key] for key in header.keys()} for row in output] +def to_json(output): + """Output is a single record""" + return "{}\n".format(json.dumps(output)) -def to_json(output, header=None): - return json.dumps(output) - - -def to_formatted_json(output, header=None): - return json.dumps(_filter(output, header), indent=4) - - -def get_dynamic_header(header_items): - return { - key: key.capitalize() - for key in header_items.keys() - if type(header_items[key]) == str - } +def to_formatted_json(output): + """Output is a single record""" + json_str = "{}\n".format(json.dumps(output, indent=4)) + return json_str diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 417b940b7..5bebaa891 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -44,20 +44,21 @@ def find_format_width(record, header, include_header=True): Finds the largest string against each column so as to decide the padding size for the column. Args: - record (list of dict), data to be formatted. - header (dict), key-value where keys should map to keys of record dict and + record (dict): data to be formatted. + header (dict): key-value where keys should map to keys of record dict and value is the corresponding column name to be displayed on the CLI. - include_header (bool), include header in output, defaults to True. + include_header (bool): include header in output, defaults to True. Returns: - tuple (list of dict, dict), i.e Filtered records, padding size of columns. + tuple (list of dict, dict): i.e Filtered records, padding size of columns. """ rows = [] if include_header: + if not header: + header = _get_default_header(record) rows.append(header) - # Set default max width items to column names - max_width_item = dict(header.items()) + max_width_item = dict(header.items()) # Copy for record_row in record: row = OrderedDict() for header_key in header.keys(): @@ -144,3 +145,17 @@ def inner(*args, **kwargs): return func(*args, **kwargs) return inner + + +def _get_default_header(header_items): + if not header_items: + return + + # Creates dict where keys and values are the same for `find_format_width()`. + header = {} + for item in header_items: + keys = item.keys() + for key in keys: + if key not in header and isinstance(key, str): + header[key] = key + return header diff --git a/tests/cmds/search/test_extraction.py b/tests/cmds/search/test_extraction.py index 7bb33b81a..584d663d1 100644 --- a/tests/cmds/search/test_extraction.py +++ b/tests/cmds/search/test_extraction.py @@ -10,7 +10,6 @@ from code42cli.output_formats import OutputFormat key = "events" -header = {"property": "Property"} class TestQuery: @@ -27,10 +26,10 @@ def test_try_get_default_header_raises_cli_error_when_using_include_all_with_non with pytest.raises(errors.Code42CLIError) as err: try_get_default_header(True, {}, OutputFormat.CSV) - assert str(err.value) == "--include-all only allowed for non-Table output formats." + assert str(err.value) == "--include-all only allowed for Table output format." -def test_try_get_default_header_uses_default_header_when_not_include_all_and_is_table(): +def test_try_get_default_header_uses_default_header_when_not_include_all(): default_header = {"default": "header"} actual = try_get_default_header(False, default_header, OutputFormat.TABLE) assert actual is default_header @@ -53,19 +52,14 @@ def __init__(self, handlers, timestamp_filter): def _get_timestamp_from_item(self, item): pass - output_format = mocker.MagicMock() + formatter = mocker.MagicMock() cursor_store = mocker.MagicMock(sepc=BaseCursorStore) handlers = create_handlers( - sdk, - TestExtractor, - cursor_store, - "chk-name", - output_format, - output_header=header, + sdk, TestExtractor, cursor_store, "chk-name", formatter, force_pager=False ) http_response = mocker.MagicMock(spec=Response) events = [{"property": "bar"}] http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) py42_response = Py42Response(http_response) handlers.handle_response(py42_response) - output_format.assert_called_once_with(events, header) + formatter.get_formatted_output.assert_called_once_with(events) diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index eaf573aeb..695d6ff9d 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -8,8 +8,8 @@ from code42cli.cmds.legal_hold import _check_matter_is_accessible from code42cli.main import cli -_NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) +_NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) TEST_MATTER_ID = "99999" TEST_LEGAL_HOLD_MEMBERSHIP_UID = "88888" TEST_LEGAL_HOLD_MEMBERSHIP_UID_2 = "77777" @@ -17,82 +17,198 @@ ACTIVE_TEST_USER_ID = "12345" INACTIVE_TEST_USERNAME = "inactive@example.com" INACTIVE_TEST_USER_ID = "54321" - TEST_POLICY_UID = "66666" - -TEST_MATTER_RESULT = { - "legalHoldUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID, +TEST_PRESERVATION_POLICY_UID = "1010101010" +MATTER_RESPONSE = """ +{ + "legalHoldUid": "88888", "name": "Test_Matter", "description": "", - "active": True, + "notes": null, + "holdExtRef": null, + "active": true, "creationDate": "2020-01-01T00:00:00.000-06:00", - "creator": {"userUid": "942564422882759874", "username": "legal_admin@example.com"}, - "holdPolicyUid": TEST_POLICY_UID, + "lastModified": "2019-12-19T20:32:10.781Z", + "creator": { + "userUid": "12345", + "username": "creator@example.com", + "email": "user@example.com", + "userExtRef": null + }, + "holdPolicyUid": "66666" } - -ACTIVE_LEGAL_HOLD_MEMBERSHIP = { - "legalHoldMembershipUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID, - "user": {"userUid": ACTIVE_TEST_USER_ID, "username": ACTIVE_TEST_USERNAME}, - "active": True, +""" +POLICY_RESPONSE = """ +{ + "legalHoldPolicyUid": "1010101010", + "name": "Test", + "creatorUser": { + "userUid": "12345", + "userId": 12345, + "username": "user@example.com", + "email": "user@example.com", + "firstName": "User", + "lastName": "User" + }, + "policy": { + "backupOpenFiles": true, + "compression": "ON", + "dataDeDupAutoMaxFileSizeForLan": 1000000000, + "dataDeDupAutoMaxFileSizeForWan": 1000000000, + "dataDeDuplication": "FULL", + "encryptionEnabled": true, + "scanIntervalMillis": 86400000, + "scanTime": "03:00", + "watchFiles": true, + "destinations": [], + "backupRunWindow": { + "alwaysRun": true, + "startTimeOfDay": "01:00", + "endTimeOfDay": "06:00", + "days": ["SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"] + }, + "backupPaths": { + "paths": [], + "excludePatterns": { + "windows": [], + "linux": [], + "macintosh": [], + "all": [] + } + }, + "retentionPolicy": { + "backupFrequencyMillis": 900000, + "keepDeleted": true, + "keepDeletedMinutes": 0, + "versionLastWeekIntervalMinutes": 15, + "versionLastNinetyDaysIntervalMinutes": 1440, + "versionLastYearIntervalMinutes": 10080, + "versionPrevYearsIntervalMinutes": 43200 + } + }, + "creationDate": "2019-05-14T16:19:09.930Z", + "modificationDate": "2019-05-14T16:19:09.930Z" +} +""" +EMPTY_CUSTODIANS_RESPONSE = """{"legalHoldMemberships": []}""" +ALL_ACTIVE_CUSTODIANS_RESPONSE = """ +{ + "legalHoldMemberships": [ + { + "legalHoldMembershipUid": "88888", + "active": true, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": { + "legalHoldUid": "99999", + "name": "test" + }, + "user": { + "userUid": "12345", + "username": "user@example.com", + "email": "user@example.com", + "userExtRef": null + } + } + ] +} +""" +ALL_INACTIVE_CUSTODIANS_RESPONSE = """ +{ + "legalHoldMemberships": [ + { + "legalHoldMembershipUid": "88888", + "active": false, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": { + "legalHoldUid": "99999", + "name": "test" + }, + "user": { + "userUid": "02345", + "username": "inactive@example.com", + "email": "user@example.com", + "userExtRef": null + } + } + ] } -INACTIVE_LEGAL_HOLD_MEMBERSHIP = { - "legalHoldMembershipUid": TEST_LEGAL_HOLD_MEMBERSHIP_UID_2, - "user": {"userUid": INACTIVE_TEST_USER_ID, "username": INACTIVE_TEST_USERNAME}, - "active": False, +""" +ALL_ACTIVE_AND_INACTIVE_CUSTODIANS_RESPONSE = """ +{ + "legalHoldMemberships": [ + { + "legalHoldMembershipUid": "88888", + "active": true, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": { + "legalHoldUid": "99999", + "name": "test" + }, + "user": { + "userUid": "12345", + "username": "user@example.com", + "email": "user@example.com", + "userExtRef": null + } + }, + { + "legalHoldMembershipUid": "88888", + "active": false, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": { + "legalHoldUid": "99999", + "name": "test" + }, + "user": { + "userUid": "02345", + "username": "inactive@example.com", + "email": "user@example.com", + "userExtRef": null + } + } + ] } +""" +EMPTY_MATTERS_RESPONSE = """{"legalHolds": []}""" +ALL_MATTERS_RESPONSE = """{{"legalHolds": [{}]}}""".format(MATTER_RESPONSE) -EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT = [{"legalHoldMemberships": []}] -ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ - {"legalHoldMemberships": [ACTIVE_LEGAL_HOLD_MEMBERSHIP]} -] -ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ - { - "legalHoldMemberships": [ - ACTIVE_LEGAL_HOLD_MEMBERSHIP, - INACTIVE_LEGAL_HOLD_MEMBERSHIP, - ] - } -] -INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT = [ - {"legalHoldMemberships": [INACTIVE_LEGAL_HOLD_MEMBERSHIP]} -] +def _create_py42_response(mocker, text): + response = mocker.MagicMock(spec=Response) + response.text = text + response._content_consumed = mocker.MagicMock() + response.status_code = 200 + return Py42Response(response) -TEST_PRESERVATION_POLICY_UID = "1010101010" -TEST_PRESERVATION_POLICY_JSON = '{{"creationDate": "2020-01-01","legalHoldPolicyUid": {}}}'.format( - TEST_PRESERVATION_POLICY_UID -) - -TEST_LEGAL_HOLD_LIST = [ - { - "legalHolds": [ - { - "legalHoldUid": "932880202064992021", - "name": "test", - "description": "", - "active": True, - "creationDate": "2019-12-19T20:32:10.763Z", - "lastModified": "2019-12-19T20:32:10.781Z", - "creator": { - "userUid": "921286907298179098", - "username": "test@test.test", - "email": "test@test.test", - }, - "holdPolicyUid": "901109555892625150", - "creator_username": "test@test.test", - }, - ], - } -] -TEST_LEGAL_HOLD_EMPTY_LIST = [{"legalHolds": []}] +@pytest.fixture +def matter_response(mocker): + return _create_py42_response(mocker, MATTER_RESPONSE) @pytest.fixture def preservation_policy_response(mocker): - response = mocker.MagicMock(spec=Response) - response.text = TEST_PRESERVATION_POLICY_JSON - return Py42Response(response) + return _create_py42_response(mocker, POLICY_RESPONSE) + + +@pytest.fixture +def empty_legal_hold_memberships_response(mocker): + return [_create_py42_response(mocker, EMPTY_CUSTODIANS_RESPONSE)] + + +@pytest.fixture +def active_legal_hold_memberships_response(mocker): + return [_create_py42_response(mocker, ALL_ACTIVE_CUSTODIANS_RESPONSE)] + + +@pytest.fixture +def inactive_legal_hold_memberships_response(mocker): + return [_create_py42_response(mocker, ALL_INACTIVE_CUSTODIANS_RESPONSE)] + + +@pytest.fixture +def active_and_inactive_legal_hold_memberships_response(mocker): + return [_create_py42_response(mocker, ALL_ACTIVE_AND_INACTIVE_CUSTODIANS_RESPONSE)] @pytest.fixture @@ -102,14 +218,24 @@ def get_user_id_success(cli_state): } +@pytest.fixture +def empty_matters_response(mocker): + return [_create_py42_response(mocker, EMPTY_MATTERS_RESPONSE)] + + +@pytest.fixture +def all_matters_response(mocker): + return [_create_py42_response(mocker, ALL_MATTERS_RESPONSE)] + + @pytest.fixture def get_user_id_failure(cli_state): cli_state.sdk.users.get_by_username.return_value = {"users": []} @pytest.fixture -def check_matter_accessible_success(cli_state): - cli_state.sdk.legalhold.get_matter_by_uid.return_value = TEST_MATTER_RESULT +def check_matter_accessible_success(cli_state, matter_response): + cli_state.sdk.legalhold.get_matter_by_uid.return_value = matter_response @pytest.fixture @@ -215,10 +341,14 @@ def test_remove_user_raises_legalhold_not_found_error_if_matter_inaccessible( def test_remove_user_raises_user_not_in_matter_error_if_user_not_active_in_matter( - runner, cli_state, check_matter_accessible_success, get_user_id_success + runner, + cli_state, + check_matter_accessible_success, + get_user_id_success, + empty_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT + empty_legal_hold_memberships_response ) result = runner.invoke( cli, @@ -239,15 +369,16 @@ def test_remove_user_raises_user_not_in_matter_error_if_user_not_active_in_matte def test_remove_user_removes_user_if_user_in_matter( - runner, cli_state, check_matter_accessible_success, get_user_id_success + runner, + cli_state, + check_matter_accessible_success, + get_user_id_success, + active_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + active_legal_hold_memberships_response ) - - membership_uid = ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT[0]["legalHoldMemberships"][0][ - "legalHoldMembershipUid" - ] + membership_uid = "88888" runner.invoke( cli, [ @@ -274,10 +405,13 @@ def test_matter_accessible_check_only_makes_one_http_call_when_called_multiple_t def test_show_matter_prints_active_and_inactive_results_when_include_inactive_flag_set( - runner, cli_state, check_matter_accessible_success + runner, + cli_state, + check_matter_accessible_success, + active_and_inactive_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + active_and_inactive_legal_hold_memberships_response ) result = runner.invoke( cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-inactive"], obj=cli_state @@ -287,10 +421,13 @@ def test_show_matter_prints_active_and_inactive_results_when_include_inactive_fl def test_show_matter_prints_active_results_only( - runner, cli_state, check_matter_accessible_success + runner, + cli_state, + check_matter_accessible_success, + active_and_inactive_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + active_and_inactive_legal_hold_memberships_response ) result = runner.invoke(cli, ["legal-hold", "show", TEST_MATTER_ID], obj=cli_state) assert ACTIVE_TEST_USERNAME in result.output @@ -298,10 +435,13 @@ def test_show_matter_prints_active_results_only( def test_show_matter_prints_no_active_members_when_no_membership( - runner, cli_state, check_matter_accessible_success + runner, + cli_state, + check_matter_accessible_success, + empty_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - EMPTY_LEGAL_HOLD_MEMBERSHIPS_RESULT + empty_legal_hold_memberships_response ) result = runner.invoke(cli, ["legal-hold", "show", TEST_MATTER_ID], obj=cli_state) assert ACTIVE_TEST_USERNAME not in result.output @@ -310,10 +450,13 @@ def test_show_matter_prints_no_active_members_when_no_membership( def test_show_matter_prints_no_inactive_members_when_no_inactive_membership( - runner, cli_state, check_matter_accessible_success + runner, + cli_state, + check_matter_accessible_success, + active_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - ACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + active_legal_hold_memberships_response ) result = runner.invoke( cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-inactive"], obj=cli_state @@ -324,10 +467,13 @@ def test_show_matter_prints_no_inactive_members_when_no_inactive_membership( def test_show_matter_prints_no_active_members_when_no_active_membership( - runner, cli_state, check_matter_accessible_success + runner, + cli_state, + check_matter_accessible_success, + inactive_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + inactive_legal_hold_memberships_response ) result = runner.invoke( cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-inactive"], obj=cli_state @@ -338,10 +484,13 @@ def test_show_matter_prints_no_active_members_when_no_active_membership( def test_show_matter_prints_no_active_members_when_no_active_membership_and_inactive_membership_included( - runner, cli_state, check_matter_accessible_success + runner, + cli_state, + check_matter_accessible_success, + inactive_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + inactive_legal_hold_memberships_response ) result = runner.invoke( cli, ["legal-hold", "show", TEST_MATTER_ID, "--include-inactive"], obj=cli_state @@ -397,9 +546,10 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): ] -def test_list_with_format_option_returns_expected_format(runner, cli_state): - cli_state.sdk.legalhold.get_all_matters.return_value = TEST_LEGAL_HOLD_LIST - +def test_list_with_format_csv_returns_csv_format( + runner, cli_state, all_matters_response +): + cli_state.sdk.legalhold.get_all_matters.return_value = all_matters_response result = runner.invoke(cli, ["legal-hold", "list", "-f", "csv"], obj=cli_state) assert "legalHoldUid" in result.output assert "name" in result.output @@ -410,29 +560,43 @@ def test_list_with_format_option_returns_expected_format(runner, cli_state): assert "creator" in result.output assert "holdPolicyUid" in result.output assert "creator_username" in result.output - assert "932880202064992021" in result.output + assert "88888" in result.output + assert "Test_Matter" in result.output + comma_count = [c for c in result.output if c == ","] + assert len(comma_count) >= 13 -def test_list_with_format_option_returns_no_response_when_response_is_empty( - runner, cli_state +def test_list_with_csv_format_returns_no_response_when_response_is_empty( + runner, cli_state, empty_legal_hold_memberships_response, empty_matters_response ): - cli_state.sdk.legalhold.get_all_matters.return_value = TEST_LEGAL_HOLD_EMPTY_LIST + cli_state.sdk.legalhold.get_all_matters.return_value = empty_matters_response result = runner.invoke(cli, ["legal-hold", "list", "-f", "csv"], obj=cli_state) assert "Matter ID,Name,Description,Creator,Creation Date" not in result.output -def test_show_with_format_option_returns_expected_format( - runner, cli_state, check_matter_accessible_success, get_user_id_success +def test_show_with_csv_format_option_returns_expected_format( + runner, + cli_state, + check_matter_accessible_success, + get_user_id_success, + active_and_inactive_legal_hold_memberships_response, ): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - ACTIVE_AND_INACTIVE_LEGAL_HOLD_MEMBERSHIPS_RESULT + active_and_inactive_legal_hold_memberships_response ) result = runner.invoke( cli, ["legal-hold", "show", TEST_MATTER_ID, "-f", "csv"], obj=cli_state ) + assert "legalHoldUid" in result.output + assert "name" in result.output + assert "description" in result.output + assert "active" in result.output + assert "creationDate" in result.output + assert "lastModified" in result.output + assert "creator" in result.output + assert "holdPolicyUid" in result.output + assert "creator_username" in result.output assert "88888" in result.output assert "Test_Matter" in result.output - assert "942564422882759874" in result.output - assert "legal_admin@example.com" in result.output - assert "66666" in result.output - assert "legal_admin@example.com" in result.output + comma_count = [c for c in result.output if c == ","] + assert len(comma_count) >= 13 diff --git a/tests/cmds/test_securitydata_output_formats.py b/tests/cmds/test_securitydata_output_formats.py index 1229ac97c..29f6c954a 100644 --- a/tests/cmds/test_securitydata_output_formats.py +++ b/tests/cmds/test_securitydata_output_formats.py @@ -3,13 +3,9 @@ import pytest from c42eventextractor.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP -from code42cli.cmds.securitydata_output_formats import ( - get_file_events_output_format_func, -) +from code42cli.cmds.search.enums import FileEventsOutputFormat +from code42cli.cmds.securitydata_output_formats import FileEventsOutputFormatter from code42cli.cmds.securitydata_output_formats import to_cef -from code42cli.output_formats import to_csv -from code42cli.output_formats import to_formatted_json -from code42cli.output_formats import to_json AED_CLOUD_ACTIVITY_EVENT_DICT = json.loads( @@ -106,114 +102,149 @@ @pytest.fixture def mock_file_event_removable_media_event(): - return [AED_REMOVABLE_MEDIA_EVENT_DICT] + return AED_REMOVABLE_MEDIA_EVENT_DICT @pytest.fixture def mock_file_event_cloud_activity_event(): - return [AED_CLOUD_ACTIVITY_EVENT_DICT] + return AED_CLOUD_ACTIVITY_EVENT_DICT @pytest.fixture def mock_file_event_email_event(): - return [AED_EMAIL_EVENT_DICT] + return AED_EMAIL_EVENT_DICT @pytest.fixture def mock_file_event(): - return [AED_EVENT_DICT] - - -def test_file_events_output_format_returns_to_dynamic_csv_function_when_csv_option_is_passed(): - extraction_output_format_function = get_file_events_output_format_func("CSV") - assert id(extraction_output_format_function) == id(to_csv) - + return AED_EVENT_DICT -def test_file_events_output_format_returns_to_formatted_json_function_when_json__option_is_passed(): - format_function = get_file_events_output_format_func("JSON") - assert id(format_function) == id(to_formatted_json) - -def test_file_events_output_format_returns_to_json_function_when_raw_json_format_option_is_passed(): - format_function = get_file_events_output_format_func("RAW-JSON") - assert id(format_function) == id(to_json) - - -def test_file_events_output_format_returns_to_cef_function_when_cef_format_option_is_passed(): - format_function = get_file_events_output_format_func("CEF") - assert id(format_function) == id(to_cef) +@pytest.fixture +def mock_to_cef(mocker): + return mocker.patch("code42cli.cmds.securitydata_output_formats.to_cef") + + +class TestFileEventsOutputFormatter: + def test_init_sets_format_func_to_dynamic_csv_function_when_csv_option_is_passed( + self, mock_to_csv + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CSV) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_csv.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_formatted_json_function_when_json__option_is_passed( + self, mock_to_formatted_json + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.JSON) + for _ in formatter.get_formatted_output(["TEST"]): + pass + mock_to_formatted_json.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_passed( + self, mock_to_json + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.RAW) + for _ in formatter.get_formatted_output(["TEST"]): + pass + mock_to_json.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_cef_function_when_cef_format_option_is_passed( + self, mock_to_cef + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CEF) + for _ in formatter.get_formatted_output(["TEST"]): + pass + mock_to_cef.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_table_function_when_table_format_option_is_passed( + self, mock_to_table + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.TABLE) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_table.assert_called_once_with("TEST", None) + + def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed( + self, mock_to_table + ): + formatter = FileEventsOutputFormatter(None) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_table.assert_called_once_with("TEST", None) def test_to_cef_returns_cef_tagged_string(mock_file_event): - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) cef_parts = get_cef_parts(cef_out) assert cef_parts[0] == "CEF:0" def test_to_cef_uses_correct_vendor_name(mock_file_event): - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) cef_parts = get_cef_parts(cef_out) assert cef_parts[1] == "Code42" def test_to_cef_uses_correct_default_product_name(mock_file_event): - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) cef_parts = get_cef_parts(cef_out) assert cef_parts[2] == "Advanced Exfiltration Detection" def test_to_cef_uses_correct_default_severity(mock_file_event): - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) cef_parts = get_cef_parts(cef_out) assert cef_parts[6] == "5" def test_to_cef_excludes_none_values_from_output(mock_file_event): - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) cef_parts = get_cef_parts(cef_out) assert "=None " not in cef_parts[-1] def test_to_cef_excludes_empty_values_from_output(mock_file_event): - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) cef_parts = get_cef_parts(cef_out) assert "= " not in cef_parts[-1] def test_to_cef_excludes_file_event_fields_not_in_cef_map(mock_file_event): test_value = "definitelyExcludedValue" - mock_file_event[0]["unmappedFieldName"] = test_value - cef_out = to_cef(mock_file_event, None) + mock_file_event["unmappedFieldName"] = test_value + cef_out = to_cef(mock_file_event) cef_parts = get_cef_parts(cef_out) - del mock_file_event[0]["unmappedFieldName"] + del mock_file_event["unmappedFieldName"] assert test_value not in cef_parts[-1] def test_to_cef_includes_os_hostname_if_present(mock_file_event): expected_field_name = "shost" expected_value = "Test's MacBook Air" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_public_ip_address_if_present(mock_file_event): expected_field_name = "src" expected_value = "71.34.4.22" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_user_uid_if_present(mock_file_event): expected_field_name = "suid" expected_value = "912338501981077099" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_device_username_if_present(mock_file_event): expected_field_name = "suser" expected_value = "test.testerson+testair@code42.com" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -222,7 +253,7 @@ def test_to_cef_includes_removable_media_capacity_if_present( ): expected_field_name = "cn1" expected_value = "5000000" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -231,7 +262,7 @@ def test_to_cef_includes_removable_media_capacity_label_if_present( ): expected_field_name = "cn1Label" expected_value = "Code42AEDRemovableMediaCapacity" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -240,7 +271,7 @@ def test_to_cef_includes_removable_media_bus_type_if_present( ): expected_field_name = "cs1" expected_value = "TEST_BUS_TYPE" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -249,7 +280,7 @@ def test_to_cef_includes_removable_media_bus_type_label_if_present( ): expected_field_name = "cs1Label" expected_value = "Code42AEDRemovableMediaBusType" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -258,7 +289,7 @@ def test_to_cef_includes_removable_media_vendor_if_present( ): expected_field_name = "cs2" expected_value = "TEST_VENDOR_NAME" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -267,7 +298,7 @@ def test_to_cef_includes_removable_media_vendor_label_if_present( ): expected_field_name = "cs2Label" expected_value = "Code42AEDRemovableMediaVendor" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -276,7 +307,7 @@ def test_to_cef_includes_removable_media_name_if_present( ): expected_field_name = "cs3" expected_value = "TEST_NAME" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -285,7 +316,7 @@ def test_to_cef_includes_removable_media_name_label_if_present( ): expected_field_name = "cs3Label" expected_value = "Code42AEDRemovableMediaName" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -294,7 +325,7 @@ def test_to_cef_includes_removable_media_serial_number_if_present( ): expected_field_name = "cs4" expected_value = "TEST_SERIAL_NUMBER" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -303,14 +334,14 @@ def test_to_cef_includes_removable_media_serial_number_label_if_present( ): expected_field_name = "cs4Label" expected_value = "Code42AEDRemovableMediaSerialNumber" - cef_out = to_cef(mock_file_event_removable_media_event, None) + cef_out = to_cef(mock_file_event_removable_media_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_actor_if_present(mock_file_event_cloud_activity_event,): expected_field_name = "suser" expected_value = "actor@example.com" - cef_out = to_cef(mock_file_event_cloud_activity_event, None) + cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -319,119 +350,119 @@ def test_to_cef_includes_sync_destination_if_present( ): expected_field_name = "destinationServiceName" expected_value = "TEST_SYNC_DESTINATION" - cef_out = to_cef(mock_file_event_cloud_activity_event, None) + cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_event_timestamp_if_present(mock_file_event): expected_field_name = "end" expected_value = "1567996943851" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_create_timestamp_if_present(mock_file_event): expected_field_name = "fileCreateTime" expected_value = "1342923569000" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_md5_checksum_if_present(mock_file_event): expected_field_name = "fileHash" expected_value = "19b92e63beb08c27ab4489fcfefbbe44" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_modify_timestamp_if_present(mock_file_event): expected_field_name = "fileModificationTime" expected_value = "1355886008000" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_file_path_if_present(mock_file_event): expected_field_name = "filePath" expected_value = "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_file_name_if_present(mock_file_event): expected_field_name = "fname" expected_value = "InfoPlist.strings" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_file_size_if_present(mock_file_event): expected_field_name = "fsize" expected_value = "86" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_file_category_if_present(mock_file_event): expected_field_name = "fileType" expected_value = "UNCATEGORIZED" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_exposure_if_present(mock_file_event): expected_field_name = "reason" expected_value = "ApplicationRead" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_url_if_present(mock_file_event_cloud_activity_event,): expected_field_name = "filePath" expected_value = "https://www.example.com" - cef_out = to_cef(mock_file_event_cloud_activity_event, None) + cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_insertion_timestamp_if_present(mock_file_event): expected_field_name = "rt" expected_value = "1568069262724" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_process_name_if_present(mock_file_event): expected_field_name = "sproc" expected_value = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_event_id_if_present(mock_file_event): expected_field_name = "externalId" expected_value = "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_device_uid_if_present(mock_file_event): expected_field_name = "deviceExternalId" expected_value = "912339407325443353" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_domain_name_if_present(mock_file_event): expected_field_name = "dvchost" expected_value = "192.168.0.3" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_source_if_present(mock_file_event): expected_field_name = "sourceServiceName" expected_value = "Endpoint" - cef_out = to_cef(mock_file_event, None) + cef_out = to_cef(mock_file_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -440,42 +471,42 @@ def test_to_cef_includes_cloud_drive_id_if_present( ): expected_field_name = "aid" expected_value = "TEST_CLOUD_DRIVE_ID" - cef_out = to_cef(mock_file_event_cloud_activity_event, None) + cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_shared_with_if_present(mock_file_event_cloud_activity_event,): expected_field_name = "duser" expected_value = "example1@example.com,example2@example.com" - cef_out = to_cef(mock_file_event_cloud_activity_event, None) + cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_tab_url_if_present(mock_file_event_cloud_activity_event,): expected_field_name = "request" expected_value = "TEST_TAB_URL" - cef_out = to_cef(mock_file_event_cloud_activity_event, None) + cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_window_title_if_present(mock_file_event_cloud_activity_event,): expected_field_name = "requestClientApplication" expected_value = "TEST_WINDOW_TITLE" - cef_out = to_cef(mock_file_event_cloud_activity_event, None) + cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_email_recipients_if_present(mock_file_event_email_event,): expected_field_name = "duser" expected_value = "test.recipient1@example.com,test.recipient2@example.com" - cef_out = to_cef(mock_file_event_email_event, None) + cef_out = to_cef(mock_file_event_email_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) def test_to_cef_includes_email_sender_if_present(mock_file_event_email_event,): expected_field_name = "suser" expected_value = "TEST_EMAIL_SENDER" - cef_out = to_cef(mock_file_event_email_event, None) + cef_out = to_cef(mock_file_event_email_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) @@ -483,8 +514,8 @@ def test_to_cef_includes_correct_event_name_and_signature_id_for_created( mock_file_event, ): event_type = "CREATED" - mock_file_event[0]["eventType"] = event_type - cef_out = to_cef(mock_file_event, None) + mock_file_event["eventType"] = event_type + cef_out = to_cef(mock_file_event) assert event_name_assigned_correct_signature_id(event_type, "C42200", cef_out) @@ -492,8 +523,8 @@ def test_to_cef_includes_correct_event_name_and_signature_id_for_modified( mock_file_event, ): event_type = "MODIFIED" - mock_file_event[0]["eventType"] = event_type - cef_out = to_cef(mock_file_event, None) + mock_file_event["eventType"] = event_type + cef_out = to_cef(mock_file_event) assert event_name_assigned_correct_signature_id(event_type, "C42201", cef_out) @@ -501,8 +532,8 @@ def test_to_cef_includes_correct_event_name_and_signature_id_for_deleted( mock_file_event, ): event_type = "DELETED" - mock_file_event[0]["eventType"] = event_type - cef_out = to_cef(mock_file_event, None) + mock_file_event["eventType"] = event_type + cef_out = to_cef(mock_file_event) assert event_name_assigned_correct_signature_id(event_type, "C42202", cef_out) @@ -510,8 +541,8 @@ def test_to_cef_includes_correct_event_name_and_signature_id_for_read_by_app( mock_file_event, ): event_type = "READ_BY_APP" - mock_file_event[0]["eventType"] = event_type - cef_out = to_cef(mock_file_event, None) + mock_file_event["eventType"] = event_type + cef_out = to_cef(mock_file_event) assert event_name_assigned_correct_signature_id(event_type, "C42203", cef_out) @@ -519,13 +550,13 @@ def test_to_cef_includes_correct_event_name_and_signature_id_for_emailed( mock_file_event_email_event, ): event_type = "EMAILED" - mock_file_event_email_event[0]["eventType"] = event_type - cef_out = to_cef(mock_file_event_email_event, None) + mock_file_event_email_event["eventType"] = event_type + cef_out = to_cef(mock_file_event_email_event) assert event_name_assigned_correct_signature_id(event_type, "C42204", cef_out) def get_cef_parts(cef_str): - return cef_str[0].split("|") + return cef_str.split("|") def key_value_pair_in_cef_extension(field_name, field_value, cef_str): diff --git a/tests/conftest.py b/tests/conftest.py index cbddf8a90..ab824ae79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -246,3 +246,23 @@ def __exit__(self, exc_type, exc_val, exc_tb): TEST_FILE_PATH = "some/path" + + +@pytest.fixture +def mock_to_table(mocker): + return mocker.patch("code42cli.output_formats.to_table") + + +@pytest.fixture +def mock_to_csv(mocker): + return mocker.patch("code42cli.output_formats.to_csv") + + +@pytest.fixture +def mock_to_json(mocker): + return mocker.patch("code42cli.output_formats.to_json") + + +@pytest.fixture +def mock_to_formatted_json(mocker): + return mocker.patch("code42cli.output_formats.to_formatted_json") diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index d993655ce..6609149b4 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -1,12 +1,7 @@ import json from collections import OrderedDict -from code42cli.output_formats import get_dynamic_header -from code42cli.output_formats import get_output_format_func -from code42cli.output_formats import to_csv -from code42cli.output_formats import to_formatted_json -from code42cli.output_formats import to_json -from code42cli.output_formats import to_table +import code42cli.output_formats as output_formats_module TEST_DATA = [ @@ -63,33 +58,6 @@ }, ] -FILTERED_OUTPUT = [ - { - "RuleId": "d12d54f0-5160-47a8-a48f-7d5fa5b051c5", - "Name": "outside td", - "Severity": "HIGH", - "Type": "FED_CLOUD_SHARE_PERMISSIONS", - "Source": "Alerting", - "Enabled": True, - }, - { - "RuleId": "8b393324-c34c-44ac-9f79-4313601dd859", - "Name": "Test different filters", - "Severity": "MEDIUM", - "Type": "FED_ENDPOINT_EXFILTRATION", - "Source": "Alerting", - "Enabled": True, - }, - { - "RuleId": "5eabed1d-a406-4dfc-af81-f7485ee09b19", - "Name": "Test Alerts using CLI", - "Severity": "HIGH", - "Type": "FED_ENDPOINT_EXFILTRATION", - "Source": "Alerting", - "Enabled": True, - }, -] - TEST_HEADER = OrderedDict() TEST_HEADER["observerRuleId"] = "RuleId" TEST_HEADER["name"] = "Name" @@ -143,71 +111,80 @@ def assert_csv_texts_are_equal(actual, expected): def test_to_csv_formats_data_to_csv_format(): - formatted_output = to_csv(TEST_DATA, None) + formatted_output = output_formats_module.to_csv(TEST_DATA) assert_csv_texts_are_equal(formatted_output, CSV_OUTPUT) def test_to_csv_when_given_no_output_returns_none(): - assert to_csv(None, None) is None + assert output_formats_module.to_csv(None) is None def test_to_table_formats_data_to_table_format(): - formatted_output = to_table(TEST_DATA, TEST_HEADER) + formatted_output = output_formats_module.to_table(TEST_DATA, TEST_HEADER) assert formatted_output == TABLE_OUTPUT def test_to_table_formats_when_given_no_output_returns_none(): - assert to_table(None, None) is None + assert output_formats_module.to_table(None, None) is None def test_to_table_when_not_given_header_creates_header_dynamically(): - formatted_output = to_table(TEST_DATA, None) + formatted_output = output_formats_module.to_table(TEST_DATA, None) assert len(formatted_output) > len(TABLE_OUTPUT) assert "test.user+partners@code42.com" in formatted_output def test_to_json(): - formatted_output = to_json(TEST_DATA, TEST_HEADER) - assert formatted_output == json.dumps(TEST_DATA) + formatted_output = output_formats_module.to_json(TEST_DATA) + assert formatted_output == "{}\n".format(json.dumps(TEST_DATA)) def test_to_formatted_json(): - formatted_output = to_formatted_json(TEST_DATA, TEST_HEADER) - assert formatted_output == json.dumps(FILTERED_OUTPUT, indent=4) - - -def test_output_format_returns_to_formatted_json_function_when_json_format_option_is_passed(): - format_function = get_output_format_func("JSON") - assert id(format_function) == id(to_formatted_json) - - -def test_output_format_returns_to_json_function_when_raw_json_format_option_is_passed(): - format_function = get_output_format_func("RAW-JSON") - assert id(format_function) == id(to_json) - - -def test_output_format_returns_to_table_function_when_ascii_table_format_option_is_passed(): - format_function = get_output_format_func("TABLE") - assert id(format_function) == id(to_table) - - -def test_output_format_returns_to_csv_function_when_csv_format_option_is_passed(): - format_function = get_output_format_func("CSV") - assert id(format_function) == id(to_csv) - - -def test_output_format_returns_to_table_function_when_no_format_option_is_passed(): - format_function = get_output_format_func(None) - assert id(format_function) == id(to_table) - - -def test_get_dynamic_header_returns_all_keys_only_which_are_not_nested(): - header = get_dynamic_header(TEST_NESTED_DATA) - assert header == { - "test": "Test", - "name": "Name", - "description": "Description", - "severity": "Severity", - "tenantId": "Tenantid", - "id": "Id", - } + formatted_output = output_formats_module.to_formatted_json(TEST_DATA) + assert formatted_output == "{}\n".format(json.dumps(TEST_DATA, indent=4)) + + +class TestOutputFormatter: + def test_init_sets_format_func_to_formatted_json_function_when_json_format_option_is_passed( + self, mock_to_json + ): + output_format = output_formats_module.OutputFormat.RAW + formatter = output_formats_module.OutputFormatter(output_format) + for _ in formatter.get_formatted_output([{"TEST": "FOOBAR"}]): + pass + mock_to_json.assert_called_once_with({"TEST": "FOOBAR"}) + + def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_passed( + self, mock_to_formatted_json + ): + output_format = output_formats_module.OutputFormat.JSON + formatter = output_formats_module.OutputFormatter(output_format) + for _ in formatter.get_formatted_output(["TEST"]): + pass + mock_to_formatted_json.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_table_function_when_table_format_option_is_passed( + self, mock_to_table + ): + output_format = output_formats_module.OutputFormat.TABLE + formatter = output_formats_module.OutputFormatter(output_format) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_table.assert_called_once_with("TEST", None) + + def test_init_sets_format_func_to_csv_function_when_csv_format_option_is_passed( + self, mock_to_csv + ): + output_format = output_formats_module.OutputFormat.CSV + formatter = output_formats_module.OutputFormatter(output_format) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_csv.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed( + self, mock_to_table + ): + formatter = output_formats_module.OutputFormatter(None) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_table.assert_called_once_with("TEST", None) From 18ecd17e20b2c400498ceefde526ebe09774b243 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 26 Aug 2020 21:18:26 +0530 Subject: [PATCH 118/349] Feature/send to (#137) * Add format to alerts and security-data search * Added global format options to alerts search command * Added global output formats options to security-data search command * Fields with array response to return data separated by ## * output format in extraction to be dynamic Change --display to --include-all, so as to make it dynamic. Added method in formats to use csv module to format data * Added pagination * Removed unused code * format option raw-json doesn't filter records based on header anymore. * Removed unused code from alerts * Extracted event processing out of extraction module * Added tests and code refactor * Added changelog * Added help text for option include-all * reverts event processing to per row Added method to format table without printing header * updated doc string * Added breaking change note in CHANGELOG * Added pagination * Set default header * refactor, fix test * improve docstring * improvise changelog * Removed unnecessary timestamp recording call * Change order of columns * removed unused method * change pagination implementation * Reverted timestamp recording back * Add test (#136) * Add test * test name * Fix style * Fix tst * Fix format error * Added extraction tests * Fix style check * Print without paging when events are less than 10 * Simplify * reorganize * Put cef back * Fix tests and mistakes found from tests * style * style * Fix tests * port cef tests * Move around output formats accordingly * test for extraction output formats * Move tests * add missing test * style * Remove log record from fixture names? * Move output format for file events * Rename enum * Remove duplicate test struct * No include all for non table formats * Share some logic * Raise and print error * tests * Sp * Make tests python 3.5 happy * The Toxicity of our city, OF OUR CITY * Use format func arg * Fix test * Format func * Style * Remove commented out code * Simplify * tests to inc coverage * More testS * Remove duplicate option * REmove commented test * Fix test name * Remove unused param * Finish removing unused arg * Add send-to command * Added send-to commands * Added tests * Refactor * removed unwanted code * Fix style tests * Inverted decorator implementation in output processing functions * Added tests for output processor methods * Fix failing test * Added changelog * Added logger for server * fix errors * fix style * changelog * Added tests for server logger * make server protocol option case insensitive * Add send to format options * fix test v3.5 * Fix CEF format and send single event at a time * Fix send-to test * Added send-to method in extractor that uses logger module to transform data in output format * make protocol option case insensitive * Added tests for extraction create_send_to_handlers * Added send-to command tests * Remove CEF support for alerts * Remove output_processor * Refactor: extract common code * Refacotor, make warning message const * Refactor, rename * Refactor, dry 1 * refactor, more dry * refactor Co-authored-by: Juliya Smith --- CHANGELOG.md | 1 + src/code42cli/cmds/alerts.py | 83 +++- src/code42cli/cmds/search/extraction.py | 80 +++- src/code42cli/cmds/securitydata.py | 97 +++- .../cmds/securitydata_output_formats.py | 1 - src/code42cli/logger.py | 52 ++ src/code42cli/options.py | 16 + src/code42cli/output_formats.py | 20 +- src/code42cli/util.py | 8 + tests/cmds/conftest.py | 9 + tests/cmds/search/test_extraction.py | 25 + tests/cmds/test_alerts.py | 447 +++++++++++++++++- tests/cmds/test_securitydata.py | 371 +++++++++++---- tests/test_logger.py | 90 ++++ tests/test_util.py | 15 + 15 files changed, 1170 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 412db3bed..0b937901c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 legal-hold list` - `code42 legal-hold show` - `code42 security-data saved-search list` +- Re-added `send-to` command to `alerts` and `security-data` that accepts a host address and a `--protocol` option with choices UDP or TCP. ### Removed diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index d64ef0751..c9d6fc6b1 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -3,7 +3,6 @@ import click import py42.sdk.queries.alerts.filters as f from c42eventextractor.extractors import AlertExtractor -from click import echo import code42cli.cmds.search.enums as enum import code42cli.cmds.search.extraction as ext @@ -11,9 +10,14 @@ import code42cli.errors as errors import code42cli.options as opt from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.cmds.search.extraction import handle_no_events +from code42cli.logger import get_logger_for_server from code42cli.options import format_option +from code42cli.options import server_options +from code42cli.output_formats import JsonOutputFormat from code42cli.output_formats import OutputFormatter + SEARCH_DEFAULT_HEADER = OrderedDict() SEARCH_DEFAULT_HEADER["name"] = "RuleName" SEARCH_DEFAULT_HEADER["actor"] = "Username" @@ -22,6 +26,7 @@ SEARCH_DEFAULT_HEADER["severity"] = "Severity" SEARCH_DEFAULT_HEADER["description"] = "Description" + search_options = searchopt.create_search_options("alerts") @@ -122,6 +127,14 @@ help="Filter alerts by description. Does fuzzy search by default.", ) +send_to_format_options = click.option( + "-f", + "--format", + type=click.Choice(JsonOutputFormat(), case_sensitive=False), + help="The output format of the result. Defaults to json format.", + default=JsonOutputFormat.JSON, +) + def alert_options(f): f = actor_option(f) @@ -137,7 +150,6 @@ def alert_options(f): f = description_option(f) f = severity_option(f) f = state_option(f) - f = format_option(f) return f @@ -157,6 +169,21 @@ def clear_checkpoint(state, checkpoint_name): _get_alert_cursor_store(state.profile.name).delete(checkpoint_name) +def _call_extractor( + cli_state, handlers, begin, end, or_query, advanced_query, **kwargs +): + extractor = _get_alert_extractor(cli_state.sdk, handlers) + extractor.use_or_query = or_query + if advanced_query: + extractor.extract_advanced(advanced_query) + else: + if begin or end: + cli_state.search_filters.append( + ext.create_time_range_filter(f.DateObserved, begin, end) + ) + extractor.extract(*cli_state.search_filters) + + @alerts.command() @alert_options @search_options @@ -170,6 +197,7 @@ def clear_checkpoint(state, checkpoint_name): is_flag=True, help="Display simple properties of the primary level of the nested response.", ) +@format_option def search( cli_state, format, @@ -195,18 +223,45 @@ def search( formatter=formatter, force_pager=include_all, ) - extractor = _get_alert_extractor(cli_state.sdk, handlers) - extractor.use_or_query = or_query - if advanced_query: - extractor.extract_advanced(advanced_query) - else: - if begin or end: - cli_state.search_filters.append( - ext.create_time_range_filter(f.DateObserved, begin, end) - ) - extractor.extract(*cli_state.search_filters) - if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: - echo("No results found.") + _call_extractor(cli_state, handlers, begin, end, or_query, advanced_query, **kwargs) + handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) + + +@alerts.command() +@alert_options +@search_options +@click.option( + "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible +) +@opt.sdk_options() +@server_options +@click.option( + "--include-all", + default=False, + is_flag=True, + help="Display simple properties of the primary level of the nested response.", +) +@send_to_format_options +def send_to( + cli_state, + format, + hostname, + protocol, + begin, + end, + advanced_query, + use_checkpoint, + or_query, + **kwargs +): + """Send alerts to the given server address.""" + logger = get_logger_for_server(hostname, protocol, format) + cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None + handlers = ext.create_send_to_handlers( + cli_state.sdk, AlertExtractor, cursor, use_checkpoint, logger, + ) + _call_extractor(cli_state, handlers, begin, end, or_query, advanced_query, **kwargs) + handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) def _get_alert_extractor(sdk, handlers): diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index 116a52078..aa72fb8a7 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -14,6 +14,9 @@ logger = get_main_cli_logger() _ALERT_DETAIL_BATCH_SIZE = 100 +INTERRUPT_WARNING = ( + "Attempting to cancel cleanly to keep checkpoint data accurate. One moment..." +) def try_get_default_header(include_all, default_header, output_format): @@ -41,10 +44,7 @@ def _get_alert_details(sdk, alert_summary_list): return results -def create_handlers( - sdk, extractor_class, cursor_store, checkpoint_name, formatter, force_pager, -): - extractor = extractor_class(sdk, ExtractionHandlers()) +def _set_handlers(cursor_store, checkpoint_name): handlers = ExtractionHandlers() handlers.TOTAL_EVENTS = 0 @@ -58,32 +58,46 @@ def handle_error(exception): secho(str(message), err=True, fg="red") handlers.handle_error = handle_error - if cursor_store: handlers.record_cursor_position = lambda value: cursor_store.replace( checkpoint_name, value ) handlers.get_cursor_position = lambda: cursor_store.get(checkpoint_name) + return handlers - @warn_interrupt( - warning="Attempting to cancel cleanly to keep checkpoint data accurate. One moment..." - ) - def handle_response(response): - response_dict = json.loads(response.text) - events = response_dict.get(extractor._key) - if extractor._key == "alerts": - try: - events = _get_alert_details(sdk, events) - except Exception as ex: - handlers.handle_error(ex) +def _get_events(sdk, handlers, extractor_key, response): + response_dict = json.loads(response.text) + events = response_dict.get(extractor_key) + if extractor_key == "alerts": + try: + events = _get_alert_details(sdk, events) + except Exception as ex: + handlers.handle_error(ex) + return events + + +def _record_timestamp(extractor, handlers, event): + last_event_timestamp = extractor._get_timestamp_from_item(event) + handlers.record_cursor_position(last_event_timestamp) + + +def create_handlers( + sdk, extractor_class, cursor_store, checkpoint_name, formatter, force_pager +): + extractor = extractor_class(sdk, ExtractionHandlers()) + handlers = _set_handlers(cursor_store, checkpoint_name) + + @warn_interrupt(warning=INTERRUPT_WARNING) + def handle_response(response): + events = _get_events(sdk, handlers, extractor._key, response) total_events = len(events) handlers.TOTAL_EVENTS += total_events def _format_output(): return formatter.get_formatted_output(events) - if len(events) > 10 or force_pager: + if total_events > 10 or force_pager: click.echo_via_pager(_format_output()) else: for page in _format_output(): @@ -93,8 +107,7 @@ def _format_output(): # To make sure the extractor records correct timestamp event when `CTRL-C` is pressed. if total_events: - last_event_timestamp = extractor._get_timestamp_from_item(events[-1]) - handlers.record_cursor_position(last_event_timestamp) + _record_timestamp(extractor, handlers, events[-1]) handlers.handle_response = handle_response return handlers @@ -123,3 +136,32 @@ def create_time_range_filter(filter_cls, begin_date=None, end_date=None): elif end_date and not begin_date: return filter_cls.on_or_before(end_date) + + +def create_send_to_handlers( + sdk, extractor_class, cursor_store, checkpoint_name, logger +): + extractor = extractor_class(sdk, ExtractionHandlers()) + handlers = _set_handlers(cursor_store, checkpoint_name) + + @warn_interrupt(warning=INTERRUPT_WARNING) + def handle_response(response): + events = _get_events(sdk, handlers, extractor._key, response) + + total_events = len(events) + handlers.TOTAL_EVENTS += total_events + + for event in events: + logger.info(event) + + # To make sure the extractor records correct timestamp event when `CTRL-C` is pressed. + if total_events: + _record_timestamp(extractor, handlers, events[-1]) + + handlers.handle_response = handle_response + return handlers + + +def handle_no_events(no_events): + if no_events: + click.echo("No results found.") diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index bcc19e3b7..c94a932c6 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -11,13 +11,18 @@ import code42cli.cmds.search.options as searchopt import code42cli.errors as errors from code42cli.cmds.search.cursor_store import FileEventCursorStore +from code42cli.cmds.search.extraction import handle_no_events from code42cli.cmds.securitydata_output_formats import FileEventsOutputFormatter +from code42cli.logger import get_logger_for_server from code42cli.logger import get_main_cli_logger from code42cli.options import format_option from code42cli.options import incompatible_with from code42cli.options import OrderedGroup from code42cli.options import sdk_options +from code42cli.options import server_options from code42cli.output_formats import OutputFormatter +from code42cli.output_formats import SendToFileEventsOutputFormat + logger = get_main_cli_logger() @@ -146,6 +151,15 @@ def _get_saved_search_query(ctx, param, arg): ) +send_to_format_options = click.option( + "-f", + "--format", + type=click.Choice(SendToFileEventsOutputFormat(), case_sensitive=False), + help="The output format of the result. Defaults to json format.", + default=SendToFileEventsOutputFormat.JSON, +) + + def file_event_options(f): f = exposure_type_option(f) f = username_option(f) @@ -158,7 +172,6 @@ def file_event_options(f): f = process_owner_option(f) f = tab_url_option(f) f = include_non_exposure_option(f) - f = file_events_format_option(f) f = saved_search_option(f) return f @@ -179,6 +192,24 @@ def clear_checkpoint(state, checkpoint_name): _get_file_event_cursor_store(state.profile.name).delete(checkpoint_name) +def _call_extractor( + state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs +): + extractor = _get_file_event_extractor(state.sdk, handlers) + extractor.use_or_query = or_query + extractor.or_query_exempt_filters.append(f.ExposureType.exists()) + if advanced_query: + extractor.extract_advanced(advanced_query) + elif saved_search: + extractor.extract(*saved_search._filter_group_list) + else: + if begin or end: + state.search_filters.append( + ext.create_time_range_filter(f.EventTimestamp, begin, end) + ) + extractor.extract(*state.search_filters) + + @security_data.command() @file_event_options @search_options @@ -192,6 +223,7 @@ def clear_checkpoint(state, checkpoint_name): is_flag=True, help="Display simple properties of the primary level of the nested response.", ) +@file_events_format_option def search( state, format, @@ -208,6 +240,7 @@ def search( output_header = ext.try_get_default_header( include_all, SEARCH_DEFAULT_HEADER, format ) + formatter = FileEventsOutputFormatter(format, output_header) cursor = ( _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None @@ -220,21 +253,11 @@ def search( formatter=formatter, force_pager=include_all, ) - extractor = _get_file_event_extractor(state.sdk, handlers) - extractor.use_or_query = or_query - extractor.or_query_exempt_filters.append(f.ExposureType.exists()) - if advanced_query: - extractor.extract_advanced(advanced_query) - elif saved_search: - extractor.extract(*saved_search._filter_group_list) - else: - if begin or end: - state.search_filters.append( - ext.create_time_range_filter(f.EventTimestamp, begin, end) - ) - extractor.extract(*state.search_filters) - if handlers.TOTAL_EVENTS == 0 and not errors.ERRORED: - echo("No results found.") + _call_extractor( + state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs + ) + + handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) @security_data.group(cls=OrderedGroup) @@ -265,6 +288,48 @@ def show(state, search_id): echo(pformat(response["searches"])) +@security_data.command() +@file_event_options +@search_options +@click.option( + "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible +) +@sdk_options() +@server_options +@click.option( + "--include-all", + default=False, + is_flag=True, + help="Display simple properties of the primary level of the nested response.", +) +@send_to_format_options +def send_to( + state, + format, + hostname, + protocol, + begin, + end, + advanced_query, + use_checkpoint, + saved_search, + or_query, + **kwargs +): + """Send events to the given server address.""" + logger = get_logger_for_server(hostname, protocol, format) + cursor = ( + _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None + ) + handlers = ext.create_send_to_handlers( + state.sdk, FileEventExtractor, cursor, use_checkpoint, logger + ) + _call_extractor( + state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs + ) + handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) + + def _get_file_event_extractor(sdk, handlers): return FileEventExtractor(sdk, handlers) diff --git a/src/code42cli/cmds/securitydata_output_formats.py b/src/code42cli/cmds/securitydata_output_formats.py index a007fd897..f42a02bef 100644 --- a/src/code42cli/cmds/securitydata_output_formats.py +++ b/src/code42cli/cmds/securitydata_output_formats.py @@ -37,7 +37,6 @@ def _convert_event_to_cef(event): } extension = " ".join(_format_cef_kvp(key, kvp_list[key]) for key in kvp_list) - event_name = event.get("eventType", "UNKNOWN") signature_id = FILE_EVENT_TO_SIGNATURE_ID_MAP.get(event_name, "C42000") diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 8d4c8f4ef..352f86dbf 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -5,6 +5,13 @@ from logging.handlers import RotatingFileHandler from threading import Lock +from c42eventextractor.logging.formatters import FileEventDictToCEFFormatter +from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter +from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter +from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper + +from code42cli.cmds.search.enums import FileEventsOutputFormat +from code42cli.util import get_url_parts from code42cli.util import get_user_project_path # prevent loggers from printing stacks to stderr if a pipe is broken @@ -14,6 +21,21 @@ ERROR_LOG_FILE_NAME = "code42_errors.log" +def _get_formatter(output_format): + if output_format == FileEventsOutputFormat.JSON: + return FileEventDictToJSONFormatter() + elif output_format == FileEventsOutputFormat.CEF: + return FileEventDictToCEFFormatter() + else: + return FileEventDictToRawJSONFormatter() + + +def _init_logger(logger, handler, output_format): + formatter = _get_formatter(output_format) + logger.setLevel(logging.INFO) + return add_handler_to_logger(logger, handler, formatter) + + def handleError(record): """Override logger's `handleError` method to exit if an exception is raised while trying to log, and replace stdout with devnull because if we're here it's usually because stdout has @@ -40,6 +62,36 @@ def get_logger_for_stdout(name_suffix="main", formatter=None): return logger +def get_logger_for_server(hostname, protocol, output_format): + """Gets the logger that sends logs to a server for the given format. + + Args: + hostname: The hostname of the server. It may include the port. + protocol: The transfer protocol for sending logs. + output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. + """ + logger = logging.getLogger("code42_syslog_{}".format(output_format.lower())) + if logger_has_handlers(logger): + return logger + + with logger_deps_lock: + if not logger_has_handlers(logger): + url_parts = get_url_parts(hostname) + port = url_parts[1] or 514 + try: + handler = NoPrioritySysLogHandlerWrapper( + url_parts[0], port=port, protocol=protocol + ).handler + except Exception as e: + raise Exception( + "Unable to connect {}. Failed with error {}".format( + hostname, str(e) + ) + ) + return _init_logger(logger, handler, output_format) + return logger + + def _get_standard_formatter(): return logging.Formatter("%(message)s") diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 25819974c..00315d14c 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -2,11 +2,13 @@ import click +from code42cli.cmds.search.enums import ServerProtocol from code42cli.errors import Code42CLIError from code42cli.output_formats import OutputFormat from code42cli.profile import get_profile from code42cli.sdk_client import create_sdk + yes_option = click.option( "-y", "--assume-yes", @@ -152,3 +154,17 @@ def __init__(self, name=None, commands=None, **attrs): def list_commands(self, ctx): return self.commands + + +def server_options(f): + hostname_arg = click.argument("hostname") + protocol_option = click.option( + "-p", + "--protocol", + type=click.Choice(ServerProtocol(), case_sensitive=False), + default=ServerProtocol.UDP, + help="Protocol used to send logs to server.", + ) + f = hostname_arg(f) + f = protocol_option(f) + return f diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index d805d3ebd..8fce325b6 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -10,16 +10,29 @@ CEF_DEFAULT_SEVERITY_LEVEL = "5" -class OutputFormat: - TABLE = "TABLE" - CSV = "CSV" +class JsonOutputFormat: JSON = "JSON" RAW = "RAW-JSON" + def __iter__(self): + return iter([self.JSON, self.RAW]) + + +class OutputFormat(JsonOutputFormat): + TABLE = "TABLE" + CSV = "CSV" + def __iter__(self): return iter([self.TABLE, self.CSV, self.JSON, self.RAW]) +class SendToFileEventsOutputFormat(JsonOutputFormat): + CEF = "CEF" + + def __iter__(self): + return iter([self.CEF, self.JSON, self.RAW]) + + class OutputFormatter: def __init__(self, output_format, header=None): output_format = output_format.upper() if output_format else OutputFormat.TABLE @@ -56,6 +69,7 @@ def _requires_list_output(self): def to_csv(output): """Output is a list of records""" + if not output: return string_io = io.StringIO() diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 5bebaa891..82eaca6b0 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -147,6 +147,14 @@ def inner(*args, **kwargs): return inner +def get_url_parts(url_str): + parts = url_str.split(":") + port = None + if len(parts) > 1 and parts[1] != "": + port = int(parts[1]) + return parts[0], port + + def _get_default_header(header_items): if not header_items: return diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 2bb9d336a..57a785a8b 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -34,6 +34,15 @@ def cli_logger(mocker): return mock +@pytest.fixture +def event_extractor_logger(mocker): + mock = mocker.patch( + "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper" + ) + mock.emit.return_value = mocker.MagicMock() + return mock + + @pytest.fixture def cli_state_with_user(sdk_with_user, cli_state): cli_state.sdk = sdk_with_user diff --git a/tests/cmds/search/test_extraction.py b/tests/cmds/search/test_extraction.py index 584d663d1..b773c7058 100644 --- a/tests/cmds/search/test_extraction.py +++ b/tests/cmds/search/test_extraction.py @@ -6,9 +6,11 @@ from code42cli import errors from code42cli.cmds.search.cursor_store import BaseCursorStore from code42cli.cmds.search.extraction import create_handlers +from code42cli.cmds.search.extraction import create_send_to_handlers from code42cli.cmds.search.extraction import try_get_default_header from code42cli.output_formats import OutputFormat + key = "events" @@ -63,3 +65,26 @@ def _get_timestamp_from_item(self, item): py42_response = Py42Response(http_response) handlers.handle_response(py42_response) formatter.get_formatted_output.assert_called_once_with(events) + + +def test_send_to_handlers_creates_handlers_that_pass_events_to_logger( + mocker, sdk, event_extractor_logger +): + class TestExtractor(BaseExtractor): + def __init__(self, handlers, timestamp_filter): + timestamp_filter._term = "test_term" + super().__init__(key, search, handlers, timestamp_filter, TestQuery) + + def _get_timestamp_from_item(self, item): + pass + + cursor_store = mocker.MagicMock(sepc=BaseCursorStore) + handlers = create_send_to_handlers( + sdk, TestExtractor, cursor_store, "chk-name", event_extractor_logger + ) + http_response = mocker.MagicMock(spec=Response) + events = [{"property": "bar"}] + http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) + py42_response = Py42Response(http_response) + handlers.handle_response(py42_response) + event_extractor_logger.info.assert_called_once_with(events[0]) diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 8697c562c..9aebe2e6f 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -300,8 +300,8 @@ def test_search_when_given_begin_date_more_than_ninety_days_back_errors( result = runner.invoke( cli, ["alerts", "search", "--begin", begin_date], obj=cli_state ) - assert result.exit_code == 2 assert "must be within 90 days" in result.output + assert result.exit_code == 2 def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( @@ -626,3 +626,448 @@ def test_search_with_or_query_flag_produces_expected_query(runner, cli_state): } actual_query = json.loads(str(cli_state.sdk.alerts.search.call_args[0][0])) assert actual_query == expected_query + + +def test_send_to_makes_call_to_the_extract_method( + cli_state, alert_extractor, runner, event_extractor_logger +): + + runner.invoke( + cli, ["alerts", "send-to", "localhost", "--begin", "1d"], obj=cli_state + ) + assert alert_extractor.extract.call_count == 1 + assert alert_extractor.extract_advanced.call_count == 0 + + +def test_send_to_makes_call_to_the_extract_advnced_method( + cli_state, alert_extractor, runner +): + + runner.invoke( + cli, + ["alerts", "send-to", "localhost", "--advanced-query", ADVANCED_QUERY_JSON], + obj=cli_state, + ) + assert alert_extractor.extract.call_count == 0 + alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') + + +def test_send_to_when_given_description_uses_description_filter( + cli_state, alert_extractor, runner +): + description = "test description" + + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--description", description], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.Description.contains(description)) in filter_strings + + +def test_send_to_with_advanced_query_uses_only_the_extract_advanced_method( + cli_state, alert_extractor, runner +): + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--advanced-query", ADVANCED_QUERY_JSON], + obj=cli_state, + ) + alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') + assert alert_extractor.extract.call_count == 0 + + +def test_send_to_without_advanced_query_uses_only_the_extract_method( + cli_state, alert_extractor, runner +): + + runner.invoke(cli, ["alerts", "send-to", "0.0.0.0", "--begin", "1d"], obj=cli_state) + assert alert_extractor.extract.call_count == 1 + assert alert_extractor.extract_advanced.call_count == 0 + + +@pytest.mark.parametrize( + "arg", + [ + ("--begin", "1d"), + ("--end", "1d"), + ("--severity", "HIGH"), + ("--actor", "test"), + ("--actor-contains", "test"), + ("--exclude-actor", "test"), + ("--exclude-actor-contains", "test"), + ("--rule-name", "test"), + ("--exclude-rule-name", "test"), + ("--rule-id", "test"), + ("--exclude-rule-id", "test"), + ("--rule-type", "FedEndpointExfiltration"), + ("--exclude-rule-type", "FedEndpointExfiltration"), + ("--description", "test"), + ("--state", "OPEN"), + ("--use-checkpoint", "test"), + ], +) +def test_send_to_with_advanced_query_and_incompatible_argument_errors( + arg, cli_state, runner +): + + result = runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +def test_send_to_when_given_begin_and_end_dates_uses_expected_query( + cli_state, alert_extractor, runner +): + begin_date = get_test_date_str(days_ago=89) + end_date = get_test_date_str(days_ago=1) + + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", begin_date, "--end", end_date], + obj=cli_state, + ) + filters = alert_extractor.extract.call_args[0][0] + actual_begin = get_filter_value_from_json(filters, filter_index=0) + expected_begin = "{}T00:00:00.000Z".format(begin_date) + actual_end = get_filter_value_from_json(filters, filter_index=1) + expected_end = "{}T23:59:59.999Z".format(end_date) + assert actual_begin == expected_begin + assert actual_end == expected_end + + +def test_send_to_when_given_begin_and_end_date_and_times_uses_expected_query( + cli_state, alert_extractor, runner +): + begin_date = get_test_date_str(days_ago=89) + end_date = get_test_date_str(days_ago=1) + time = "15:33:02" + runner.invoke( + cli, + [ + "alerts", + "search", + "--begin", + "{} {}".format(begin_date, time), + "--end", + "{} {}".format(end_date, time), + ], + obj=cli_state, + ) + filters = alert_extractor.extract.call_args[0][0] + actual_begin = get_filter_value_from_json(filters, filter_index=0) + expected_begin = "{}T{}.000Z".format(begin_date, time) + actual_end = get_filter_value_from_json(filters, filter_index=1) + expected_end = "{}T{}.000Z".format(end_date, time) + assert actual_begin == expected_begin + assert actual_end == expected_end + + +def test_send_to_when_given_begin_date_and_time_without_seconds_uses_expected_query( + cli_state, alert_extractor, runner +): + date = get_test_date_str(days_ago=89) + time = "15:33" + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", "{} {}".format(date, time)], + obj=cli_state, + ) + actual = get_filter_value_from_json( + alert_extractor.extract.call_args[0][0], filter_index=0 + ) + expected = "{}T{}:00.000Z".format(date, time) + assert actual == expected + + +def test_send_to_when_given_end_date_and_time_uses_expected_query( + cli_state, alert_extractor, runner +): + begin_date = get_test_date_str(days_ago=10) + end_date = get_test_date_str(days_ago=1) + time = "15:33" + runner.invoke( + cli, + [ + "alerts", + "search", + "--begin", + begin_date, + "--end", + "{} {}".format(end_date, time), + ], + obj=cli_state, + ) + actual = get_filter_value_from_json( + alert_extractor.extract.call_args[0][0], filter_index=1 + ) + expected = "{}T{}:00.000Z".format(end_date, time) + assert actual == expected + + +def test_send_to_when_given_begin_date_more_than_ninety_days_back_errors( + cli_state, runner +): + begin_date = get_test_date_str(days_ago=91) + " 12:51:00" + result = runner.invoke( + cli, ["alerts", "send-to", "0.0.0.0", "--begin", begin_date], obj=cli_state + ) + assert "must be within 90 days" in result.output + + +def test_send_to_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + cli_state, alert_cursor_with_checkpoint, alert_extractor, runner +): + begin_date = get_test_date_str(days_ago=91) + " 12:51:00" + runner.invoke( + cli, + [ + "alerts", + "send-to", + "0.0.0.0", + "--begin", + begin_date, + "--use-checkpoint", + "test", + ], + obj=cli_state, + ) + assert not filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) + + +def test_send_to_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( + cli_state, alert_extractor, runner +): + begin_date = get_test_date_str(days_ago=1) + runner.invoke( + cli, ["alerts", "send-to", "0.0.0.0", "--begin", begin_date], obj=cli_state + ) + actual_ts = get_filter_value_from_json( + alert_extractor.extract.call_args[0][0], filter_index=0 + ) + expected_ts = "{}T00:00:00.000Z".format(begin_date) + assert actual_ts == expected_ts + assert filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) + + +def test_send_to_when_end_date_is_before_begin_date_causes_exit(cli_state, runner): + begin_date = get_test_date_str(days_ago=1) + end_date = get_test_date_str(days_ago=3) + result = runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", begin_date, "--end", end_date], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "'--begin': cannot be after --end date" in result.output + + +def test_send_to_with_only_begin_calls_extract_with_expected_filters( + cli_state, alert_extractor, begin_option, runner +): + result = runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", ""], + obj=cli_state, + ) + assert result.exit_code == 0 + assert str( + alert_extractor.extract.call_args[0][0] + ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"createdAt", "value":"{}"}}]}}'.format( + begin_option.expected_timestamp + ) + + +def test_send_to_with_use_checkpoint_and_without_begin_and_without_stored_checkpoint_causes_expected_error( + cli_state, alert_cursor_without_checkpoint, runner +): + result = runner.invoke( + cli, ["alerts", "send-to", "0.0.0.0", "--use-checkpoint", "test"], obj=cli_state + ) + assert result.exit_code == 2 + assert ( + "--begin date is required for --use-checkpoint when no checkpoint exists yet." + in result.output + ) + + +def test_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( + cli_state, alert_extractor, begin_option, alert_cursor_without_checkpoint, runner, +): + result = runner.invoke( + cli, + [ + "alerts", + "search", + "--use-checkpoint", + "test", + "--begin", + "", + ], + obj=cli_state, + ) + assert result.exit_code == 0 + assert len(alert_extractor.extract.call_args[0]) == 1 + assert begin_option.expected_timestamp in str( + alert_extractor.extract.call_args[0][0] + ) + + +def test_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( + cli_state, alert_extractor, alert_cursor_with_checkpoint, runner +): + + result = runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--use-checkpoint", "test", "--begin", "1h"], + obj=cli_state, + ) + assert result.exit_code == 0 + alert_extractor.extract.assert_called_with() + assert ( + "checkpoint of {} exists".format( + alert_cursor_with_checkpoint.expected_timestamp + ) + in result.output + ) + + +def test_send_to_when_given_actor_is_uses_username_filter( + cli_state, alert_extractor, runner +): + actor_name = "test.testerson" + + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--actor", actor_name], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.Actor.is_in([actor_name])) in filter_strings + + +def test_send_to_when_given_exclude_actor_uses_actor_filter( + cli_state, alert_extractor, runner +): + actor_name = "test.testerson" + + runner.invoke( + cli, + [ + "alerts", + "send-to", + "0.0.0.0", + "--begin", + "1h", + "--exclude-actor", + actor_name, + ], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.Actor.not_in([actor_name])) in filter_strings + + +def test_send_to_when_given_rule_name_uses_rule_name_filter( + cli_state, alert_extractor, runner +): + rule_name = "departing employee" + + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--rule-name", rule_name], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.RuleName.is_in([rule_name])) in filter_strings + + +def test_send_to_when_given_exclude_rule_name_uses_rule_name_not_filter( + cli_state, alert_extractor, runner +): + rule_name = "departing employee" + + runner.invoke( + cli, + [ + "alerts", + "send-to", + "0.0.0.0", + "--begin", + "1h", + "--exclude-rule-name", + rule_name, + ], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.RuleName.not_in([rule_name])) in filter_strings + + +def test_send_to_when_given_rule_type_uses_rule_name_filter( + cli_state, alert_extractor, runner +): + rule_type = "FedEndpointExfiltration" + + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--rule-type", rule_type], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.RuleType.is_in([rule_type])) in filter_strings + + +def test_send_to_when_given_exclude_rule_type_uses_rule_name_not_filter( + cli_state, alert_extractor, runner +): + rule_type = "FedEndpointExfiltration" + + runner.invoke( + cli, + [ + "alerts", + "send-to", + "0.0.0.0", + "--begin", + "1h", + "--exclude-rule-type", + rule_type, + ], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.RuleType.not_in([rule_type])) in filter_strings + + +def test_send_to_when_given_rule_id_uses_rule_name_filter( + cli_state, alert_extractor, runner +): + rule_id = "departing employee" + + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--rule-id", rule_id], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.RuleId.is_in([rule_id])) in filter_strings + + +def test_send_to_when_given_exclude_rule_id_uses_rule_name_not_filter( + cli_state, alert_extractor, runner +): + rule_id = "departing employee" + + runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--exclude-rule-id", rule_id], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] + assert str(f.RuleId.not_in([rule_id])) in filter_strings diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index d03162ed8..236a22f84 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -78,14 +78,23 @@ def begin_option(mocker): ADVANCED_QUERY_JSON = '{"some": "complex json"}' +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON], + [ + "security-data", + "send-to", + "0.0.0.0", + "--advanced-query", + ADVANCED_QUERY_JSON, + ], + ), +) def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( - runner, cli_state, file_event_extractor + runner, cli_state, file_event_extractor, command ): - runner.invoke( - cli, - ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON], - obj=cli_state, - ) + runner.invoke(cli, command, obj=cli_state) file_event_extractor.extract_advanced.assert_called_once_with( '{"some": "complex json"}' ) @@ -93,10 +102,17 @@ def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( assert file_event_extractor.extract_advanced.call_count == 1 +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1d"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1d"], + ), +) def test_search_when_is_not_advanced_query_uses_only_the_extract_advanced_method( - runner, cli_state, file_event_extractor + runner, cli_state, file_event_extractor, command ): - runner.invoke(cli, ["security-data", "search", "--begin", "1d"], obj=cli_state) + runner.invoke(cli, command, obj=cli_state) assert file_event_extractor.extract_advanced.call_count == 0 assert file_event_extractor.extract.call_count == 1 @@ -163,15 +179,35 @@ def test_search_with_saved_search_and_incompatible_argument_errors( assert "{} can't be used with: --saved-search".format(arg[0]) in result.output -def test_search_when_given_begin_and_end_dates_uses_expected_query( - runner, cli_state, file_event_extractor +@pytest.mark.parametrize( + "command", + ( + [ + "security-data", + "search", + "--begin", + get_test_date_str(days_ago=89), + "--end", + get_test_date_str(days_ago=1), + ], + [ + "security-data", + "send-to", + "0.0.0.0", + "--begin", + get_test_date_str(days_ago=89), + "--end", + get_test_date_str(days_ago=1), + ], + ), +) +def test_command_when_given_begin_and_end_dates_uses_expected_query( + runner, cli_state, file_event_extractor, command ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) runner.invoke( - cli, - ["security-data", "search", "--begin", begin_date, "--end", end_date], - obj=cli_state, + cli, command, obj=cli_state, ) filters = file_event_extractor.extract.call_args[0][1] actual_begin = get_filter_value_from_json(filters, filter_index=0) @@ -251,13 +287,28 @@ def test_search_when_given_end_date_and_time_uses_expected_query( assert actual == expected -def test_search_when_given_begin_date_more_than_ninety_days_back_errors( - runner, cli_state, +@pytest.mark.parametrize( + "command", + ( + [ + "security-data", + "search", + "--begin", + get_test_date_str(days_ago=91) + " 12:51:00", + ], + [ + "security-data", + "send-to", + "0.0.0.0", + "--begin", + get_test_date_str(days_ago=91) + " 12:51:00", + ], + ), +) +def test_command_when_given_begin_date_more_than_ninety_days_back_errors( + runner, cli_state, command ): - begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - result = runner.invoke( - cli, ["security-data", "search", "--begin", begin_date], obj=cli_state - ) + result = runner.invoke(cli, command, obj=cli_state) assert result.exit_code == 2 assert "must be within 90 days" in result.output @@ -330,18 +381,30 @@ def test_search_with_use_checkpoint_and_without_begin_and_without_checkpoint_cau ) -def test_search_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], + [ + "security-data", + "send-to", + "0.0.0.0", + "--use-checkpoint", + "test", + "--begin", + "1h", + ], + ), +) +def test_command_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( runner, cli_state, file_event_extractor, begin_option, file_event_cursor_without_checkpoint, + command, ): - result = runner.invoke( - cli, - ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], - obj=cli_state, - ) + result = runner.invoke(cli, command, obj=cli_state,) assert result.exit_code == 0 assert len(file_event_extractor.extract.call_args[0]) == 2 assert begin_option.expected_timestamp in str( @@ -349,14 +412,25 @@ def test_search_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_ ) -def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( - runner, cli_state, file_event_extractor, file_event_cursor_with_checkpoint, -): - result = runner.invoke( - cli, +@pytest.mark.parametrize( + "command", + ( ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], - obj=cli_state, - ) + [ + "security-data", + "send-to", + "0.0.0.0", + "--use-checkpoint", + "test", + "--begin", + "1h", + ], + ), +) +def test_command_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( + runner, cli_state, file_event_extractor, file_event_cursor_with_checkpoint, command, +): + result = runner.invoke(cli, command, obj=cli_state,) assert result.exit_code == 0 assert len(file_event_extractor.extract.call_args[0]) == 1 assert ( @@ -367,159 +441,243 @@ def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_ca ) -def test_search_when_given_invalid_exposure_type_causes_exit(runner, cli_state): - result = runner.invoke( - cli, +@pytest.mark.parametrize( + "command", + ( ["security-data", "search", "--begin", "1d", "-t", "NotValid"], - obj=cli_state, - ) + ["security-data", "send-to", "0.0.0.0", "--begin", "1d", "-t", "NotValid"], + ), +) +def test_command_when_given_invalid_exposure_type_causes_exit( + runner, cli_state, command +): + result = runner.invoke(cli, command, obj=cli_state,) assert result.exit_code == 2 assert "invalid choice: NotValid" in result.output -def test_search_when_given_username_uses_username_filter( - runner, cli_state, file_event_extractor +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--c42-username"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--c42-username"], + ), +) +def test_command_when_given_username_uses_username_filter( + runner, cli_state, file_event_extractor, command ): c42_username = "test@code42.com" + command.append(c42_username) runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--c42-username", c42_username], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.DeviceUsername.is_in([c42_username])) in filter_strings -def test_search_when_given_actor_is_uses_username_filter( - runner, cli_state, file_event_extractor +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--actor"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--actor"], + ), +) +def test_command_when_given_actor_is_uses_username_filter( + runner, cli_state, file_event_extractor, command ): actor_name = "test.testerson" + command.append(actor_name) runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--actor", actor_name], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.Actor.is_in([actor_name])) in filter_strings -def test_search_when_given_md5_uses_md5_filter(runner, cli_state, file_event_extractor): +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--md5"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--md5"], + ), +) +def test_command_when_given_md5_uses_md5_filter( + runner, cli_state, file_event_extractor, command +): md5 = "abcd12345" - runner.invoke( - cli, ["security-data", "search", "--begin", "1h", "--md5", md5], obj=cli_state - ) + command.append(md5) + runner.invoke(cli, command, obj=cli_state) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.MD5.is_in([md5])) in filter_strings -def test_search_when_given_sha256_uses_sha256_filter( - runner, cli_state, file_event_extractor +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--sha256"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--sha256"], + ), +) +def test_command_when_given_sha256_uses_sha256_filter( + runner, cli_state, file_event_extractor, command ): sha_256 = "abcd12345" + command.append(sha_256) runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--sha256", sha_256], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.SHA256.is_in([sha_256])) in filter_strings -def test_search_when_given_source_uses_source_filter( - runner, cli_state, file_event_extractor +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--source"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--source"], + ), +) +def test_command_when_given_source_uses_source_filter( + runner, cli_state, file_event_extractor, command ): source = "Gmail" - runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--source", source], - obj=cli_state, - ) + command.append(source) + runner.invoke(cli, command, obj=cli_state) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.Source.is_in([source])) in filter_strings -def test_search_when_given_file_name_uses_file_name_filter( - runner, cli_state, file_event_extractor +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--file-name"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--file-name"], + ), +) +def test_command_when_given_file_name_uses_file_name_filter( + runner, cli_state, file_event_extractor, command ): filename = "test.txt" + command.append(filename) runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--file-name", filename], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.FileName.is_in([filename])) in filter_strings -def test_search_when_given_file_path_uses_file_path_filter( - runner, cli_state, file_event_extractor +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--file-path"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--file-path"], + ), +) +def test_command_when_given_file_path_uses_file_path_filter( + runner, cli_state, file_event_extractor, command ): filepath = "C:\\Program Files" + command.append(filepath) runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--file-path", filepath], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.FilePath.is_in([filepath])) in filter_strings +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--process-owner"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--process-owner"], + ), +) def test_when_given_process_owner_uses_process_owner_filter( - runner, cli_state, file_event_extractor + runner, cli_state, file_event_extractor, command ): process_owner = "root" + command.append(process_owner) runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--process-owner", process_owner], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.ProcessOwner.is_in([process_owner])) in filter_strings +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--tab-url"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--tab-url"], + ), +) def test_when_given_tab_url_uses_process_tab_url_filter( - runner, cli_state, file_event_extractor + runner, cli_state, file_event_extractor, command ): tab_url = "https://example.com" + command.append(tab_url) runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--tab-url", tab_url], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.TabURL.is_in([tab_url])) in filter_strings +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--type"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--type"], + ), +) def test_when_given_exposure_types_uses_exposure_type_is_in_filter( - runner, cli_state, file_event_extractor + runner, cli_state, file_event_extractor, command ): exposure_type = "SharedViaLink" + command.append(exposure_type) runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--type", exposure_type], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.ExposureType.is_in([exposure_type])) in filter_strings +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h", "--include-non-exposure"], + [ + "security-data", + "send-to", + "0.0.0.0", + "--begin", + "1h", + "--include-non-exposure", + ], + ), +) def test_when_given_include_non_exposure_does_not_include_exposure_type_exists( - runner, cli_state, file_event_extractor + runner, cli_state, file_event_extractor, command ): runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--include-non-exposure"], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.ExposureType.exists()) not in filter_strings +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--begin", "1h"], + ["security-data", "send-to", "0.0.0.0", "--begin", "1h"], + ), +) def test_when_not_given_include_non_exposure_includes_exposure_type_exists( - runner, cli_state, file_event_extractor + runner, cli_state, file_event_extractor, command ): runner.invoke( - cli, ["security-data", "search", "--begin", "1h"], obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.ExposureType.exists()) in filter_strings @@ -726,3 +884,34 @@ def test_saved_search_list_with_format_option_does_not_return_when_response_is_e cli, ["security-data", "saved-search", "list", "-f", "csv"], obj=cli_state ) assert "Name,Id" not in result.output + + +def test_send_to_when_is_advanced_query_uses_only_the_extract_advanced_method( + runner, cli_state, file_event_extractor, event_extractor_logger +): + runner.invoke( + cli, + [ + "security-data", + "send-to", + "localhost", + "--advanced-query", + ADVANCED_QUERY_JSON, + ], + obj=cli_state, + ) + file_event_extractor.extract_advanced.assert_called_once_with( + '{"some": "complex json"}' + ) + assert file_event_extractor.extract.call_count == 0 + assert file_event_extractor.extract_advanced.call_count == 1 + + +def test_send_to_when_is_not_advanced_query_uses_only_the_extract_advanced_method( + runner, cli_state, file_event_extractor +): + runner.invoke( + cli, ["security-data", "send-to", "localhost", "--begin", "1d"], obj=cli_state + ) + assert file_event_extractor.extract_advanced.call_count == 0 + assert file_event_extractor.extract.call_count == 1 diff --git a/tests/test_logger.py b/tests/test_logger.py index 29c32d6b7..275150894 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -2,15 +2,31 @@ import os from logging.handlers import RotatingFileHandler +import pytest +from c42eventextractor.logging.formatters import FileEventDictToCEFFormatter +from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter +from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter from requests import Request from code42cli.logger import add_handler_to_logger from code42cli.logger import CliLogger +from code42cli.logger import get_logger_for_server from code42cli.logger import get_view_error_details_message from code42cli.logger import logger_has_handlers from code42cli.util import get_user_project_path +@pytest.fixture +def no_priority_syslog_handler(mocker): + mock = mocker.patch( + "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.handler" + ) + + # Set handlers to empty list so it gets initialized each test + get_logger_for_server("example.com", "TCP", "CEF").handlers = [] + return mock + + def test_add_handler_to_logger_does_as_expected(): logger = logging.getLogger("TEST_CODE42_CLI") formatter = logging.Formatter() @@ -40,6 +56,80 @@ def test_get_view_exceptions_location_message_returns_expected_message(): assert actual == expected +def test_get_logger_for_server_has_info_level(no_priority_syslog_handler): + logger = get_logger_for_server("example.com", "TCP", "CEF") + assert logger.level == logging.INFO + + +def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter( + no_priority_syslog_handler, +): + get_logger_for_server("example.com", "TCP", "CEF") + assert ( + type(no_priority_syslog_handler.setFormatter.call_args[0][0]) + == FileEventDictToCEFFormatter + ) + + +def test_get_logger_for_server_when_given_json_format_uses_json_formatter( + no_priority_syslog_handler, +): + get_logger_for_server("example.com", "TCP", "JSON").handlers = [] + get_logger_for_server("example.com", "TCP", "JSON") + actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) + assert actual == FileEventDictToJSONFormatter + + +def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter( + no_priority_syslog_handler, +): + get_logger_for_server("example.com", "TCP", "RAW-JSON").handlers = [] + get_logger_for_server("example.com", "TCP", "RAW-JSON") + actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) + assert actual == FileEventDictToRawJSONFormatter + + +def test_get_logger_for_server_when_called_twice_only_has_one_handler( + no_priority_syslog_handler, +): + get_logger_for_server("example.com", "TCP", "JSON") + logger = get_logger_for_server("example.com", "TCP", "CEF") + assert len(logger.handlers) == 1 + + +def test_get_logger_for_server_uses_no_priority_syslog_handler( + no_priority_syslog_handler, +): + logger = get_logger_for_server("example.com", "TCP", "CEF") + assert logger.handlers[0] == no_priority_syslog_handler + + +def test_get_logger_for_server_constructs_handler_with_expected_args( + mocker, no_priority_syslog_handler, monkeypatch +): + no_priority_syslog_handler_wrapper = mocker.patch( + "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" + ) + no_priority_syslog_handler_wrapper.return_value = None + get_logger_for_server("example.com", "TCP", "CEF") + no_priority_syslog_handler_wrapper.assert_called_once_with( + "example.com", port=514, protocol="TCP" + ) + + +def test_get_logger_for_server_when_hostname_includes_port_constructs_handler_with_expected_args( + mocker, no_priority_syslog_handler +): + no_priority_syslog_handler_wrapper = mocker.patch( + "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" + ) + no_priority_syslog_handler_wrapper.return_value = None + get_logger_for_server("example.com:999", "TCP", "CEF") + no_priority_syslog_handler_wrapper.assert_called_once_with( + "example.com", port=999, protocol="TCP" + ) + + class TestCliLogger: _logger = CliLogger() diff --git a/tests/test_util.py b/tests/test_util.py index 98901a440..274c9e2c2 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,6 +5,7 @@ from code42cli.util import does_user_agree from code42cli.util import find_format_width from code42cli.util import format_string_list_to_columns +from code42cli.util import get_url_parts TEST_HEADER = {"key1": "Column 1", "key2": "Column 10", "key3": "Column 100"} @@ -155,3 +156,17 @@ def test_format_string_list_to_columns_uses_width_of_longest_string(echo_output) printed_row = echo_output.call_args_list[1][0][0] assert len(printed_row) == expected_row_width assert printed_row == "col2_that_is_really_long " + + +def test_url_parts(): + server, port = get_url_parts("localhost:3000") + assert server == "localhost" + assert port == 3000 + + server, port = get_url_parts("localhost") + assert server == "localhost" + assert port is None + + server, port = get_url_parts("127.0.0.1") + assert server == "127.0.0.1" + assert port is None From 956ed49eb152419281e07b5ac6df170d162dad7b Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 28 Aug 2020 12:36:21 -0500 Subject: [PATCH 119/349] Feature/file cat (#130) --- src/code42cli/cmds/alerts.py | 10 +-- src/code42cli/cmds/detectionlists/enums.py | 21 ------- src/code42cli/cmds/high_risk_employee.py | 4 +- src/code42cli/cmds/search/enums.py | 73 ---------------------- src/code42cli/cmds/securitydata.py | 13 +++- tests/cmds/test_securitydata.py | 15 +++++ 6 files changed, 35 insertions(+), 101 deletions(-) delete mode 100644 src/code42cli/cmds/detectionlists/enums.py diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index c9d6fc6b1..a844f5940 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -3,8 +3,10 @@ import click import py42.sdk.queries.alerts.filters as f from c42eventextractor.extractors import AlertExtractor +from py42.sdk.queries.alerts.filters import AlertState +from py42.sdk.queries.alerts.filters import RuleType +from py42.sdk.queries.alerts.filters import Severity -import code42cli.cmds.search.enums as enum import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt import code42cli.errors as errors @@ -33,7 +35,7 @@ severity_option = click.option( "--severity", multiple=True, - type=click.Choice(enum.AlertSeverity()), + type=click.Choice(Severity.choices()), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.is_in_filter(f.Severity), help="Filter alerts by severity. Defaults to returning all severities.", @@ -41,7 +43,7 @@ state_option = click.option( "--state", multiple=True, - type=click.Choice(enum.AlertState()), + type=click.Choice(AlertState.choices()), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.is_in_filter(f.AlertState), help="Filter alerts by status. Defaults to returning all statuses.", @@ -107,7 +109,7 @@ rule_type_option = click.option( "--rule-type", multiple=True, - type=click.Choice(enum.RuleType()), + type=click.Choice(RuleType.choices()), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.is_in_filter(f.RuleType), help="Filter alerts by including the given rule type(s).", diff --git a/src/code42cli/cmds/detectionlists/enums.py b/src/code42cli/cmds/detectionlists/enums.py deleted file mode 100644 index 0a22d92b7..000000000 --- a/src/code42cli/cmds/detectionlists/enums.py +++ /dev/null @@ -1,21 +0,0 @@ -class RiskTags: - FLIGHT_RISK = "FLIGHT_RISK" - HIGH_IMPACT_EMPLOYEE = "HIGH_IMPACT_EMPLOYEE" - ELEVATED_ACCESS_PRIVILEGES = "ELEVATED_ACCESS_PRIVILEGES" - PERFORMANCE_CONCERNS = "PERFORMANCE_CONCERNS" - SUSPICIOUS_SYSTEM_ACTIVITY = "SUSPICIOUS_SYSTEM_ACTIVITY" - POOR_SECURITY_PRACTICES = "POOR_SECURITY_PRACTICES" - CONTRACT_EMPLOYEE = "CONTRACT_EMPLOYEE" - - def __iter__(self): - return iter( - [ - self.FLIGHT_RISK, - self.HIGH_IMPACT_EMPLOYEE, - self.ELEVATED_ACCESS_PRIVILEGES, - self.PERFORMANCE_CONCERNS, - self.SUSPICIOUS_SYSTEM_ACTIVITY, - self.POOR_SECURITY_PRACTICES, - self.CONTRACT_EMPLOYEE, - ] - ) diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 242ca1712..e22fac845 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -1,4 +1,5 @@ import click +from py42.clients.detectionlists import RiskTags from py42.exceptions import Py42BadRequestError from code42cli.bulk import generate_template_cmd_factory @@ -8,7 +9,6 @@ from code42cli.cmds.detectionlists import remove_risk_tags as _remove_risk_tags from code42cli.cmds.detectionlists import try_handle_user_already_added_error from code42cli.cmds.detectionlists import update_user -from code42cli.cmds.detectionlists.enums import RiskTags from code42cli.cmds.detectionlists.options import cloud_alias_option from code42cli.cmds.detectionlists.options import notes_option from code42cli.cmds.detectionlists.options import username_arg @@ -22,7 +22,7 @@ "-t", "--risk-tag", multiple=True, - type=click.Choice(RiskTags()), + type=click.Choice(RiskTags.choices()), help="Risk tags associated with the employee.", ) diff --git a/src/code42cli/cmds/search/enums.py b/src/code42cli/cmds/search/enums.py index 4ecbfedc0..09c8d5e8b 100644 --- a/src/code42cli/cmds/search/enums.py +++ b/src/code42cli/cmds/search/enums.py @@ -10,79 +10,6 @@ def __iter__(self): return iter([self.TABLE, self.CSV, self.JSON, self.RAW, self.CEF]) -class AlertSeverity: - HIGH = "HIGH" - MEDIUM = "MEDIUM" - LOW = "LOW" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [self.HIGH, self.MEDIUM, self.LOW] - - -class AlertState: - OPEN = "OPEN" - DISMISSED = "RESOLVED" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [self.OPEN, self.DISMISSED] - - -class ExposureType: - SHARED_VIA_LINK = "SharedViaLink" - SHARED_TO_DOMAIN = "SharedToDomain" - APPLICATION_READ = "ApplicationRead" - CLOUD_STORAGE = "CloudStorage" - REMOVABLE_MEDIA = "RemovableMedia" - IS_PUBLIC = "IsPublic" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [ - self.SHARED_VIA_LINK, - self.SHARED_TO_DOMAIN, - self.APPLICATION_READ, - self.CLOUD_STORAGE, - self.REMOVABLE_MEDIA, - self.IS_PUBLIC, - ] - - -class RuleType: - ENDPOINT_EXFILTRATION = "FedEndpointExfiltration" - CLOUD_SHARE_PERMISSIONS = "FedCloudSharePermissions" - FILE_TYPE_MISMATCH = "FedFileTypeMismatch" - - def __iter__(self): - return iter(self._as_list()) - - def __len__(self): - return len(self._as_list()) - - def _as_list(self): - return [ - self.ENDPOINT_EXFILTRATION, - self.CLOUD_SHARE_PERMISSIONS, - self.FILE_TYPE_MISMATCH, - ] - - class ServerProtocol: TCP = "TCP" UDP = "UDP" diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index c94a932c6..96219db9e 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -5,6 +5,8 @@ import py42.sdk.queries.fileevents.filters as f from c42eventextractor.extractors import FileEventExtractor from click import echo +from py42.sdk.queries.fileevents.filters.exposure_filter import ExposureType +from py42.sdk.queries.fileevents.filters.file_filter import FileCategory import code42cli.cmds.search.enums as enum import code42cli.cmds.search.extraction as ext @@ -58,7 +60,7 @@ "-t", "--type", multiple=True, - type=click.Choice(list(enum.ExposureType())), + type=click.Choice(list(ExposureType.choices())), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, callback=searchopt.is_in_filter(f.ExposureType), help="Limits events to those with given exposure types.", @@ -113,6 +115,14 @@ cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file is located at one of these paths. Applies to endpoint file events only.", ) +file_category_option = click.option( + "--file-category", + multiple=True, + type=click.Choice(list(FileCategory.choices())), + callback=searchopt.is_in_filter(f.FileCategory), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to file events where the file can be classified by one of these categories.", +) process_owner_option = click.option( "--process-owner", multiple=True, @@ -169,6 +179,7 @@ def file_event_options(f): f = source_option(f) f = file_name_option(f) f = file_path_option(f) + f = file_category_option(f) f = process_owner_option(f) f = tab_url_option(f) f = include_non_exposure_option(f) diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 236a22f84..6459d6c25 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -129,6 +129,7 @@ def test_search_when_is_not_advanced_query_uses_only_the_extract_advanced_method ("--source", "Gmail"), ("--file-name", "test.txt"), ("--file-path", "C:\\Program Files"), + ("--file-category", "IMAGE"), ("--process-owner", "root"), ("--tab-url", "https://example.com"), ("--type", "SharedViaLink"), @@ -160,6 +161,7 @@ def test_search_with_advanced_query_and_incompatible_argument_errors( ("--source", "Gmail"), ("--file-name", "test.txt"), ("--file-path", "C:\\Program Files"), + ("--file-category", "IMAGE"), ("--process-owner", "root"), ("--tab-url", "https://example.com"), ("--type", "SharedViaLink"), @@ -585,6 +587,19 @@ def test_command_when_given_file_path_uses_file_path_filter( assert str(f.FilePath.is_in([filepath])) in filter_strings +def test_search_when_given_file_category_uses_file_category_filter( + runner, cli_state, file_event_extractor +): + file_category = "IMAGE" + runner.invoke( + cli, + ["security-data", "search", "--begin", "1h", "--file-category", file_category], + obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(f.FileCategory.is_in([file_category])) in filter_strings + + @pytest.mark.parametrize( "command", ( From 0ff17615ec7d3985b243b47e62e6cd49722592fa Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Fri, 28 Aug 2020 12:36:57 -0500 Subject: [PATCH 120/349] Update detectionlists.md (#145) --- docs/userguides/detectionlists.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/userguides/detectionlists.md b/docs/userguides/detectionlists.md index 2e7a9142c..0dbec2056 100644 --- a/docs/userguides/detectionlists.md +++ b/docs/userguides/detectionlists.md @@ -1,7 +1,8 @@ # Manage Detection List Users -Use the `departing-employee` commands to add or remove employees on the Departing Employees list or High Risk list, or update the details for a user. To -see a list of all the users currently in your organization, you can export a list from the +Use the `departing-employee` commands to add employees to or remove employees from the Departing Employees list. Use the `high-risk-employee` commands to add employees to or remove employees from the High Risk list, or update risk tags for those users. + +To see a list of all the users currently in your organization, you can export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Administration_console_reference/Users_reference#Action_menu). ## Get CSV template From 985ba899ac0a1f47c8f1b003bee5a162f5d35306 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 28 Aug 2020 13:55:25 -0500 Subject: [PATCH 121/349] Remove extra space from raw json for syslog purposes (#146) --- src/code42cli/output_formats.py | 2 +- tests/test_output_formats.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 8fce325b6..c7420ade7 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -90,7 +90,7 @@ def to_table(output, header): def to_json(output): """Output is a single record""" - return "{}\n".format(json.dumps(output)) + return "{}".format(json.dumps(output)) def to_formatted_json(output): diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index 6609149b4..b01f4cda8 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -136,7 +136,7 @@ def test_to_table_when_not_given_header_creates_header_dynamically(): def test_to_json(): formatted_output = output_formats_module.to_json(TEST_DATA) - assert formatted_output == "{}\n".format(json.dumps(TEST_DATA)) + assert formatted_output == json.dumps(TEST_DATA) def test_to_formatted_json(): From 2e5d3751bb06ef668a3489df0f6aa11f483d49f0 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 28 Aug 2020 14:35:02 -0500 Subject: [PATCH 122/349] some rows might not contain all keys, so use .get() (#147) --- src/code42cli/util.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 82eaca6b0..33466f7c0 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -57,14 +57,14 @@ def find_format_width(record, header, include_header=True): if not header: header = _get_default_header(record) rows.append(header) - max_width_item = dict(header.items()) # Copy for record_row in record: row = OrderedDict() for header_key in header.keys(): - row[header_key] = record_row[header_key] + item = record_row.get(header_key) + row[header_key] = item max_width_item[header_key] = max( - max_width_item[header_key], str(record_row[header_key]), key=len + max_width_item[header_key], str(item), key=len ) rows.append(row) column_size = {key: len(value) for key, value in max_width_item.items()} From 9e636f6d9aed6fecc5419ee15005aba45766360e Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 28 Aug 2020 15:54:33 -0500 Subject: [PATCH 123/349] Chore/remove error handling (#132) --- setup.py | 2 +- src/code42cli/cmds/alert_rules.py | 27 +-- src/code42cli/cmds/departing_employee.py | 10 +- src/code42cli/cmds/detectionlists/__init__.py | 13 -- src/code42cli/cmds/high_risk_employee.py | 13 +- src/code42cli/cmds/legal_hold.py | 22 +-- src/code42cli/cmds/shared.py | 4 +- src/code42cli/errors.py | 35 ++-- tests/cmds/conftest.py | 31 +--- tests/cmds/test_alert_rules.py | 173 +++++++++--------- tests/cmds/test_departing_employee.py | 69 +++---- tests/cmds/test_high_risk_employee.py | 47 ++--- tests/conftest.py | 5 +- tests/test_errors.py | 0 14 files changed, 181 insertions(+), 270 deletions(-) create mode 100644 tests/test_errors.py diff --git a/setup.py b/setup.py index 3c9bb8f3a..b2c7a9fa5 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ "c42eventextractor==0.4.0", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42>=1.7.0", + "py42>=1.8.1", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index 98ce2d8e0..3accf756f 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -2,7 +2,6 @@ import click from click import echo -from py42.exceptions import Py42InternalServerError from py42.util import format_json from code42cli import PRODUCT_NAME @@ -10,7 +9,6 @@ from code42cli.bulk import run_bulk_process from code42cli.cmds.shared import get_user_id from code42cli.errors import Code42CLIError -from code42cli.errors import InvalidRuleTypeError from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.options import OrderedGroup @@ -152,23 +150,15 @@ def handle_row(rule_id, username): def _add_user(sdk, rule_id, username): user_id = get_user_id(sdk, username) rules = _get_rule_metadata(sdk, rule_id) - try: - if rules: - sdk.alerts.rules.add_user(rule_id, user_id) - except Py42InternalServerError: - _check_if_system_rule(rules) - raise + if rules: + sdk.alerts.rules.add_user(rule_id, user_id) def _remove_user(sdk, rule_id, username): user_id = get_user_id(sdk, username) rules = _get_rule_metadata(sdk, rule_id) - try: - if rules: - sdk.alerts.rules.remove_user(rule_id, user_id) - except Py42InternalServerError: - _check_if_system_rule(rules) - raise + if rules: + sdk.alerts.rules.remove_user(rule_id, user_id) def _get_all_rules_metadata(sdk): @@ -185,18 +175,13 @@ def _get_rule_metadata(sdk, rule_id): def _handle_rules_results(rules, rule_id=None): - id_msg = "with RuleId {} ".format(rule_id) if rule_id else "" - msg = "No alert rules {}found.".format(id_msg) if not rules: + id_msg = "with RuleId {} ".format(rule_id) if rule_id else "" + msg = "No alert rules {}found.".format(id_msg) echo(msg) return rules -def _check_if_system_rule(rules): - if rules and rules[0]["isSystem"]: - raise InvalidRuleTypeError(rules[0]["observerRuleId"], rules[0]["ruleSource"]) - - def _get_rule_type_func(sdk, rule_type): if rule_type == AlertRuleTypes.EXFILTRATION: return sdk.alerts.rules.exfiltration.get diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 0c4d7330c..aca67cd18 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -1,9 +1,7 @@ import click -from py42.exceptions import Py42BadRequestError from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process -from code42cli.cmds.detectionlists import try_handle_user_already_added_error from code42cli.cmds.detectionlists import update_user from code42cli.cmds.detectionlists.options import cloud_alias_option from code42cli.cmds.detectionlists.options import notes_option @@ -124,12 +122,8 @@ def handle_row(username): def _add_departing_employee(sdk, username, cloud_alias, departure_date, notes): user_id = get_user_id(sdk, username) - try: - sdk.detectionlists.departing_employee.add(user_id, departure_date) - update_user(sdk, username, cloud_alias=cloud_alias, notes=notes) - except Py42BadRequestError as err: - try_handle_user_already_added_error(err, username, "departing-employee list") - raise + sdk.detectionlists.departing_employee.add(user_id, departure_date) + update_user(sdk, username, cloud_alias=cloud_alias, notes=notes) def _remove_departing_employee(sdk, username): diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 1d66a2012..f05aa2ab1 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,5 +1,4 @@ from code42cli.cmds.shared import get_user_id -from code42cli.errors import UserAlreadyAddedError def update_user(sdk, username, cloud_alias=None, risk_tag=None, notes=None): @@ -33,18 +32,6 @@ def remove_risk_tags(sdk, username, risk_tag): sdk.detectionlists.remove_user_risk_tags(user_id, risk_tag) -def try_handle_user_already_added_error( - bad_request_err, username_tried_adding, list_name -): - if _error_is_user_already_added(bad_request_err.response.text): - raise UserAlreadyAddedError(username_tried_adding, list_name) - return False - - -def _error_is_user_already_added(bad_request_error_text): - return "User already on list" in bad_request_error_text - - def handle_list_args(list_arg): """Converts str args to a list. Useful for `bulk` commands which don't use click's argument parsing but instead pass in values from files, such as in the form "item1 item2".""" diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index e22fac845..9cb66936f 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -1,13 +1,11 @@ import click from py42.clients.detectionlists import RiskTags -from py42.exceptions import Py42BadRequestError from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process from code42cli.cmds.detectionlists import add_risk_tags as _add_risk_tags from code42cli.cmds.detectionlists import handle_list_args from code42cli.cmds.detectionlists import remove_risk_tags as _remove_risk_tags -from code42cli.cmds.detectionlists import try_handle_user_already_added_error from code42cli.cmds.detectionlists import update_user from code42cli.cmds.detectionlists.options import cloud_alias_option from code42cli.cmds.detectionlists.options import notes_option @@ -174,15 +172,8 @@ def handle_row(username, tag): def _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes): risk_tag = handle_list_args(risk_tag) user_id = get_user_id(sdk, username) - - try: - sdk.detectionlists.high_risk_employee.add(user_id) - update_user( - sdk, username, cloud_alias=cloud_alias, risk_tag=risk_tag, notes=notes - ) - except Py42BadRequestError as err: - try_handle_user_already_added_error(err, username, "high risk employees list") - raise + sdk.detectionlists.high_risk_employee.add(user_id) + update_user(sdk, username, cloud_alias=cloud_alias, risk_tag=risk_tag, notes=notes) def _remove_high_risk_employee(sdk, username): diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index bdff5cd16..d39dea47d 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -5,14 +5,10 @@ import click from click import echo -from py42.exceptions import Py42BadRequestError -from py42.exceptions import Py42ForbiddenError from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process from code42cli.cmds.shared import get_user_id -from code42cli.errors import LegalHoldNotFoundOrPermissionDeniedError -from code42cli.errors import UserAlreadyAddedError from code42cli.errors import UserNotInLegalHoldError from code42cli.file_readers import read_csv_arg from code42cli.options import format_option @@ -185,16 +181,8 @@ def handle_row(matter_id, username): def _add_user_to_legal_hold(sdk, matter_id, username): user_id = get_user_id(sdk, username) - matter = _check_matter_is_accessible(sdk, matter_id) - try: - sdk.legalhold.add_to_matter(user_id, matter_id) - except Py42BadRequestError as e: - if "USER_ALREADY_IN_HOLD" in e.response.text: - matter_id_and_name_text = "legal hold matter id={}, name={}".format( - matter_id, matter["name"] - ) - raise UserAlreadyAddedError(username, matter_id_and_name_text) - raise + _check_matter_is_accessible(sdk, matter_id) + sdk.legalhold.add_to_matter(user_id, matter_id) def _remove_user_from_legal_hold(sdk, matter_id, username): @@ -255,8 +243,4 @@ def _print_matter_members(username_list, member_type="active"): @lru_cache(maxsize=None) def _check_matter_is_accessible(sdk, matter_id): - try: - matter = sdk.legalhold.get_matter_by_uid(matter_id) - return matter - except (Py42BadRequestError, Py42ForbiddenError): - raise LegalHoldNotFoundOrPermissionDeniedError(matter_id) + return sdk.legalhold.get_matter_by_uid(matter_id) diff --git a/src/code42cli/cmds/shared.py b/src/code42cli/cmds/shared.py index c8c1a3688..4fa7a29bc 100644 --- a/src/code42cli/cmds/shared.py +++ b/src/code42cli/cmds/shared.py @@ -5,8 +5,8 @@ @lru_cache(maxsize=None) def get_user_id(sdk, username): - """Returns the user's UID (referred to by `user_id` in detection lists). Raises - `UserDoesNotExistError` if the user doesn't exist in the Code42 server. + """Returns the user's UID (referred to by `user_id` in detection lists). + Raises `UserDoesNotExistError` if the user doesn't exist in the Code42 server. Args: sdk (py42.sdk.SDKClient): The py42 sdk. diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index 824512573..d55f117c1 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -5,6 +5,9 @@ from click._compat import get_text_stderr from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42HTTPError +from py42.exceptions import Py42InvalidRuleOperationError +from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError +from py42.exceptions import Py42UserAlreadyAddedError from code42cli.logger import get_main_cli_logger from code42cli.logger import get_view_error_details_message @@ -52,19 +55,6 @@ def format_message(self): ) -class UserAlreadyAddedError(Code42CLIError): - def __init__(self, username, list_name): - msg = "'{}' is already on the {}.".format(username, list_name) - super().__init__(msg) - - -class InvalidRuleTypeError(Code42CLIError): - def __init__(self, rule_id, source): - msg = "Only alert rules with a source of 'Alerting' can be targeted by this command. " - msg += "Rule {0} has a source of '{1}'." - super().__init__(msg.format(rule_id, source)) - - class UserDoesNotExistError(Code42CLIError): """An error to represent a username that is not in our system. The CLI shows this error when the user tries to add or remove a user that does not exist. This error is not shown during @@ -77,20 +67,12 @@ def __init__(self, username): class UserNotInLegalHoldError(Code42CLIError): def __init__(self, username, matter_id): super().__init__( - "User '{}' is not an active member of legal hold matter '{}'".format( + "User '{}' is not an active member of legal hold matter '{}'.".format( username, matter_id ) ) -class LegalHoldNotFoundOrPermissionDeniedError(Code42CLIError): - def __init__(self, matter_id): - super().__init__( - "Matter with id={} either does not exist or your profile does not have permission to " - "view it.".format(matter_id) - ) - - class ExceptionHandlingGroup(click.Group): """Custom click.Group subclass to add custom exception handling.""" @@ -124,6 +106,15 @@ def invoke(self, ctx): except click.exceptions.Exit: raise + except ( + UserDoesNotExistError, + Py42UserAlreadyAddedError, + Py42InvalidRuleOperationError, + Py42LegalHoldNotFoundOrPermissionDeniedError, + ) as err: + self.logger.log_error(err) + raise Code42CLIError(str(err)) + except Py42ForbiddenError as err: self.logger.log_verbose_error(self._original_args, err.response.request) raise LoggedCLIError( diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 57a785a8b..887d87951 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -2,16 +2,19 @@ import threading import pytest -from py42.exceptions import Py42BadRequestError +from py42.exceptions import Py42UserAlreadyAddedError from py42.sdk import SDKClient from requests import HTTPError -from requests import Request from requests import Response from tests.conftest import convert_str_to_date +from tests.conftest import TEST_ID from code42cli.logger import CliLogger +TEST_EMPLOYEE = "risky employee" + + @pytest.fixture def sdk(mocker): return mocker.MagicMock(spec=SDKClient) @@ -56,26 +59,12 @@ def cli_state_without_user(sdk_without_user, cli_state): @pytest.fixture -def bad_request_for_user_already_added(mocker): - resp = mocker.MagicMock(spec=Response) - resp.text = "User already on list" - return _create_bad_request_mock(resp) - - -@pytest.fixture -def generic_bad_request(mocker): +def user_already_added_error(mocker): + err = mocker.MagicMock(spec=HTTPError) resp = mocker.MagicMock(spec=Response) - req = mocker.MagicMock(spec=Request) - req.body = '{"test":"body"}' - resp.request = req - resp.text = "TEST" - return _create_bad_request_mock(resp) - - -def _create_bad_request_mock(resp): - base_err = HTTPError() - base_err.response = resp - return Py42BadRequestError(base_err) + resp.text = "TEST_ERR" + err.response = resp + return Py42UserAlreadyAddedError(err, TEST_ID, "detection list") def get_filter_value_from_json(json, filter_index): diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index b2acfdbad..fcfb5bb73 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -1,7 +1,10 @@ +import json import logging import pytest from py42.exceptions import Py42InternalServerError +from py42.exceptions import Py42InvalidRuleOperationError +from py42.response import Py42Response from requests import HTTPError from requests import Request from requests import Response @@ -11,20 +14,10 @@ TEST_RULE_ID = "rule-id" TEST_USER_ID = "test-user-id" TEST_USERNAME = "test@code42.com" +TEST_SOURCE = "rule source" TEST_EMPTY_RULE_RESPONSE = {"ruleMetadata": []} -TEST_SYSTEM_RULE_RESPONSE = { - "ruleMetadata": [ - { - "observerRuleId": TEST_RULE_ID, - "type": "FED_FILE_TYPE_MISMATCH", - "isSystem": True, - "ruleSource": "NOTVALID", - } - ] -} - TEST_RULE_RESPONSE = { "ruleMetadata": [ { @@ -38,17 +31,6 @@ ] } -TEST_USER_RULE_RESPONSE = { - "ruleMetadata": [ - { - "observerRuleId": TEST_RULE_ID, - "type": "FED_FILE_TYPE_MISMATCH", - "isSystem": False, - "ruleSource": "Testing", - } - ] -} - TEST_GET_ALL_RESPONSE_EXFILTRATION = { "ruleMetadata": [ {"observerRuleId": TEST_RULE_ID, "type": "FED_ENDPOINT_EXFILTRATION"} @@ -64,33 +46,57 @@ } +def get_rule_not_found_side_effect(mocker): + def side_effect(*args, **kwargs): + response = mocker.MagicMock(spec=Response) + response.text = json.dumps(TEST_EMPTY_RULE_RESPONSE) + return Py42Response(response) + + return side_effect + + +def create_invalid_rule_type_side_effect(mocker): + def side_effect(*args, **kwargs): + err = mocker.MagicMock(spec=HTTPError) + resp = mocker.MagicMock(spec=Response) + resp.text = "TEST_ERR" + err.response = resp + raise Py42InvalidRuleOperationError(err, TEST_RULE_ID, TEST_SOURCE) + + return side_effect + + @pytest.fixture def get_user_id(mocker): return mocker.patch("code42cli.cmds.alert_rules.get_user_id") @pytest.fixture -def alert_rules_sdk(sdk): - sdk.alerts.rules.add_user.return_value = {} - sdk.alerts.rules.remove_user.return_value = {} - sdk.alerts.rules.remove_all_users.return_value = {} - sdk.alerts.rules.get_all.return_value = {} - sdk.alerts.rules.exfiltration.get.return_value = {} - sdk.alerts.rules.cloudshare.get.return_value = {} - sdk.alerts.rules.filetypemismatch.get.return_value = {} - return sdk +def mock_server_error(mocker): + base_err = _get_error_base(mocker) + return Py42InternalServerError(base_err) -@pytest.fixture -def mock_server_error(mocker): +def _get_error_base(mocker): base_err = HTTPError() mock_response = mocker.MagicMock(spec=Response) base_err.response = mock_response request = mocker.MagicMock(spec=Request) request.body = '{"test":"body"}' base_err.response.request = request + return base_err - return Py42InternalServerError(base_err) + +@pytest.fixture +def alert_rules_sdk(sdk): + sdk.alerts.rules.add_user.return_value = {} + sdk.alerts.rules.remove_user.return_value = {} + sdk.alerts.rules.remove_all_users.return_value = {} + sdk.alerts.rules.get_all.return_value = {} + sdk.alerts.rules.exfiltration.get.return_value = {} + sdk.alerts.rules.cloudshare.get.return_value = {} + sdk.alerts.rules.filetypemismatch.get.return_value = {} + return sdk def test_add_user_adds_user_list_to_alert_rules(runner, cli_state): @@ -107,26 +113,12 @@ def test_add_user_adds_user_list_to_alert_rules(runner, cli_state): ) -def test_add_user_when_non_existent_alert_prints_no_rules_message(runner, cli_state): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( - TEST_EMPTY_RULE_RESPONSE - ) - result = runner.invoke( - cli, - ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], - obj=cli_state, - ) - msg = "No alert rules with RuleId {} found".format(TEST_RULE_ID) - assert msg in result.output - - -def test_add_user_when_returns_500_and_system_rule_exits_with_InvalidRuleTypeError( - runner, cli_state, mock_server_error +def test_add_user_when_returns_invalid_rule_type_error_and_system_rule_exits( + mocker, runner, cli_state ): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( - TEST_SYSTEM_RULE_RESPONSE + cli_state.sdk.alerts.rules.add_user.side_effect = create_invalid_rule_type_side_effect( + mocker ) - cli_state.sdk.alerts.rules.add_user.side_effect = mock_server_error result = runner.invoke( cli, ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], @@ -137,12 +129,12 @@ def test_add_user_when_returns_500_and_system_rule_exits_with_InvalidRuleTypeErr "Only alert rules with a source of 'Alerting' can be targeted by this command." in result.output ) + assert "Rule rule-id has a source of 'rule source'." in result.output def test_add_user_when_returns_500_and_not_system_rule_raises_Py42InternalServerError( runner, cli_state, mock_server_error, caplog ): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_USER_RULE_RESPONSE cli_state.sdk.alerts.rules.add_user.side_effect = mock_server_error with caplog.at_level(logging.ERROR): result = runner.invoke( @@ -154,6 +146,18 @@ def test_add_user_when_returns_500_and_not_system_rule_raises_Py42InternalServer assert "Py42InternalServerError" in caplog.text +def test_add_user_when_rule_not_found_prints_expected_output(mocker, runner, cli_state): + cli_state.sdk.alerts.rules.get_by_observer_id.side_effect = get_rule_not_found_side_effect( + mocker + ) + result = runner.invoke( + cli, + ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + assert "No alert rules with RuleId rule-id found." in result.output + + def test_remove_user_removes_user_list_from_alert_rules(runner, cli_state): cli_state.sdk.users.get_by_username.return_value = { "users": [{"userUid": TEST_USER_ID}] @@ -168,58 +172,57 @@ def test_remove_user_removes_user_list_from_alert_rules(runner, cli_state): ) -def test_remove_user_when_non_existent_alert_prints_no_rules_message(runner, cli_state): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( - TEST_EMPTY_RULE_RESPONSE +def test_remove_user_when_raise_invalid_rule_type_error_and_system_rule_raises_InvalidRuleTypeError( + mocker, runner, cli_state +): + cli_state.sdk.alerts.rules.remove_user.side_effect = create_invalid_rule_type_side_effect( + mocker ) result = runner.invoke( cli, ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], obj=cli_state, ) - msg = "No alert rules with RuleId {} found".format(TEST_RULE_ID) - assert msg in result.output + assert result.exit_code == 1 + assert ( + "Only alert rules with a source of 'Alerting' can be targeted by this command." + in result.output + ) + assert "Rule rule-id has a source of 'rule source'." in result.output -def test_remove_user_when_returns_500_and_system_rule_raises_InvalidRuleTypeError( - runner, cli_state, mock_server_error +def test_remove_user_when_rule_not_found_prints_expected_output( + mocker, runner, cli_state ): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = ( - TEST_SYSTEM_RULE_RESPONSE + cli_state.sdk.alerts.rules.get_by_observer_id.side_effect = get_rule_not_found_side_effect( + mocker + ) + result = runner.invoke( + cli, + ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], + obj=cli_state, + ) + assert "No alert rules with RuleId rule-id found." in result.output + + +def test_remove_user_when_raises_invalid_rule_type_side_effect_and_not_system_rule_raises_Py42InternalServerError( + mocker, runner, cli_state +): + cli_state.sdk.alerts.rules.remove_user.side_effect = create_invalid_rule_type_side_effect( + mocker ) - cli_state.sdk.alerts.rules.remove_user.side_effect = mock_server_error result = runner.invoke( cli, ["alert-rules", "remove-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], obj=cli_state, ) + assert result.exit_code == 1 assert ( "Only alert rules with a source of 'Alerting' can be targeted by this command." in result.output ) - - -def test_remove_user_when_returns_500_and_not_system_rule_raises_Py42InternalServerError( - runner, cli_state, mock_server_error, caplog -): - cli_state.sdk.alerts.rules.get_by_observer_id.return_value = TEST_USER_RULE_RESPONSE - cli_state.sdk.alerts.rules.remove_user.side_effect = mock_server_error - with caplog.at_level(logging.ERROR): - result = runner.invoke( - cli, - [ - "alert-rules", - "remove-user", - "--rule-id", - TEST_RULE_ID, - "-u", - TEST_USERNAME, - ], - obj=cli_state, - ) - assert result.exit_code == 1 - assert "Py42InternalServerError" in caplog.text + assert "Rule rule-id has a source of 'rule source'." in result.output def test_list_gets_alert_rules(runner, cli_state): diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 178f012e3..3e1b3009c 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -1,19 +1,17 @@ from tests.cmds.conftest import thread_safe_side_effect from tests.conftest import TEST_ID +from .conftest import TEST_EMPLOYEE from code42cli.main import cli -_EMPLOYEE = "departing employee" - - def test_add_departing_employee_when_given_cloud_alias_adds_alias( runner, cli_state_with_user ): alias = "departing employee alias" runner.invoke( cli, - ["departing-employee", "add", _EMPLOYEE, "--cloud-alias", alias], + ["departing-employee", "add", TEST_EMPLOYEE, "--cloud-alias", alias], obj=cli_state_with_user, ) cli_state_with_user.sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( @@ -27,7 +25,7 @@ def test_add_departing_employee_when_given_notes_updates_notes( notes = "is leaving" runner.invoke( cli, - ["departing-employee", "add", _EMPLOYEE, "--notes", notes], + ["departing-employee", "add", TEST_EMPLOYEE, "--notes", notes], obj=cli_state_with_user, ) cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with( @@ -41,7 +39,13 @@ def test_add_departing_employee_adds( departure_date = "2020-02-02" runner.invoke( cli, - ["departing-employee", "add", _EMPLOYEE, "--departure-date", departure_date], + [ + "departing-employee", + "add", + TEST_EMPLOYEE, + "--departure-date", + departure_date, + ], obj=cli_state_with_user, ) cli_state_with_user.sdk.detectionlists.departing_employee.add.assert_called_once_with( @@ -53,42 +57,29 @@ def test_add_departing_employee_when_user_does_not_exist_exits( runner, cli_state_without_user ): result = runner.invoke( - cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_without_user + cli, ["departing-employee", "add", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert "User '{}' does not exist.".format(_EMPLOYEE) in result.output + assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output -def test_add_departing_employee_when_user_already_added_raises_UserAlreadyAddedError( - runner, cli_state_with_user, bad_request_for_user_already_added +def test_add_departing_employee_when_user_already_exits_with_correct_message( + mocker, runner, cli_state_with_user, user_already_added_error ): - cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = ( - bad_request_for_user_already_added - ) - result = runner.invoke( - cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_with_user - ) - assert result.exit_code == 1 - assert "'{}' is already on the departing-employee list.".format(_EMPLOYEE) - + def add_user(user): + raise user_already_added_error -def test_add_departing_employee_when_bad_request_but_not_user_already_added_raises_Py42BadRequestError( - runner, cli_state_with_user, generic_bad_request -): - cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = ( - generic_bad_request - ) + cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = add_user result = runner.invoke( - cli, ["departing-employee", "add", _EMPLOYEE], obj=cli_state_with_user + cli, ["departing-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user ) assert result.exit_code == 1 - assert "Problem making request to server." in result.output - assert "View details in" in result.output + assert "'{}' is already on the departing-employee list.".format(TEST_EMPLOYEE) def test_remove_departing_employee_calls_remove(runner, cli_state_with_user): runner.invoke( - cli, ["departing-employee", "remove", _EMPLOYEE], obj=cli_state_with_user + cli, ["departing-employee", "remove", TEST_EMPLOYEE], obj=cli_state_with_user ) cli_state_with_user.sdk.detectionlists.departing_employee.remove.assert_called_once_with( TEST_ID @@ -99,10 +90,10 @@ def test_remove_departing_employee_when_user_does_not_exist_exits( runner, cli_state_without_user ): result = runner.invoke( - cli, ["departing-employee", "remove", _EMPLOYEE], obj=cli_state_without_user + cli, ["departing-employee", "remove", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert "User '{}' does not exist.".format(_EMPLOYEE) in result.output + assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output def test_add_bulk_users_calls_expected_py42_methods(runner, mocker, cli_state): @@ -167,7 +158,13 @@ def test_add_departing_employee_when_invalid_date_validation_raises_error( departure_date = "2020-02-30" result = runner.invoke( cli, - ["departing-employee", "add", _EMPLOYEE, "--departure-date", departure_date], + [ + "departing-employee", + "add", + TEST_EMPLOYEE, + "--departure-date", + departure_date, + ], obj=cli_state_with_user, ) assert result.exit_code == 2 @@ -182,7 +179,13 @@ def test_add_departing_employee_when_invalid_date_format_validation_raises_error departure_date = "2020-30-01" result = runner.invoke( cli, - ["departing-employee", "add", _EMPLOYEE, "--departure-date", departure_date], + [ + "departing-employee", + "add", + TEST_EMPLOYEE, + "--departure-date", + departure_date, + ], obj=cli_state_with_user, ) assert result.exit_code == 2 diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 814829450..cd76d8c7c 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -1,15 +1,15 @@ +from tests.cmds.conftest import TEST_EMPLOYEE from tests.cmds.conftest import thread_safe_side_effect from tests.conftest import TEST_ID from code42cli.main import cli _NAMESPACE = "code42cli.cmds.high_risk_employee" -_EMPLOYEE = "risky employee" def test_add_high_risk_employee_adds(runner, cli_state_with_user): runner.invoke( - cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user + cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user ) cli_state_with_user.sdk.detectionlists.high_risk_employee.add.assert_called_once_with( TEST_ID @@ -22,7 +22,7 @@ def test_add_high_risk_employee_when_given_cloud_alias_adds_alias( alias = "risk employee alias" runner.invoke( cli, - ["high-risk-employee", "add", _EMPLOYEE, "--cloud-alias", alias], + ["high-risk-employee", "add", TEST_EMPLOYEE, "--cloud-alias", alias], obj=cli_state_with_user, ) cli_state_with_user.sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( @@ -38,7 +38,7 @@ def test_add_high_risk_employee_when_given_risk_tags_adds_tags( [ "high-risk-employee", "add", - _EMPLOYEE, + TEST_EMPLOYEE, "-t", "FLIGHT_RISK", "-t", @@ -60,7 +60,7 @@ def test_add_high_risk_employee_when_given_notes_updates_notes( notes = "being risky" runner.invoke( cli, - ["high-risk-employee", "add", _EMPLOYEE, "--notes", notes], + ["high-risk-employee", "add", TEST_EMPLOYEE, "--notes", notes], obj=cli_state_with_user, ) cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with( @@ -72,45 +72,30 @@ def test_add_high_risk_employee_when_user_does_not_exist_exits_with_correct_mess runner, cli_state_without_user ): result = runner.invoke( - cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_without_user + cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert "User '{}' does not exist.".format(_EMPLOYEE) in result.output + assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output def test_add_high_risk_employee_when_user_already_added_exits_with_correct_message( - runner, cli_state_with_user, bad_request_for_user_already_added + mocker, runner, cli_state_with_user, user_already_added_error ): - cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = ( - bad_request_for_user_already_added - ) - result = runner.invoke( - cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user - ) - assert result.exit_code == 1 - assert ( - "'{}' is already on the high risk employees list.".format(_EMPLOYEE) - in result.output - ) + def add_user(user): + raise user_already_added_error + cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = add_user -def test_add_high_risk_employee_when_bad_request_but_not_user_already_added_exits_with_message_to_see_logs( - runner, cli_state_with_user, generic_bad_request -): - cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = ( - generic_bad_request - ) result = runner.invoke( - cli, ["high-risk-employee", "add", _EMPLOYEE], obj=cli_state_with_user + cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user ) assert result.exit_code == 1 - assert "Problem making request to server." in result.output - assert "View details in" in result.output + assert "User with ID TEST_ID is already on the detection list" in result.output def test_remove_high_risk_employee_calls_remove(runner, cli_state_with_user): runner.invoke( - cli, ["high-risk-employee", "remove", _EMPLOYEE], obj=cli_state_with_user + cli, ["high-risk-employee", "remove", TEST_EMPLOYEE], obj=cli_state_with_user ) cli_state_with_user.sdk.detectionlists.high_risk_employee.remove.assert_called_once_with( TEST_ID @@ -121,10 +106,10 @@ def test_remove_high_risk_employee_when_user_does_not_exist_exits_with_correct_m runner, cli_state_without_user ): result = runner.invoke( - cli, ["high-risk-employee", "remove", _EMPLOYEE], obj=cli_state_without_user + cli, ["high-risk-employee", "remove", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert "User '{}' does not exist.".format(_EMPLOYEE) in result.output + assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output def test_generate_template_file_when_given_add_generates_template_from_handler( diff --git a/tests/conftest.py b/tests/conftest.py index ab824ae79..3e440c802 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,8 @@ from code42cli.options import CLIState from code42cli.profile import Code42Profile +TEST_ID = "TEST_ID" + @pytest.fixture def runner(): @@ -93,9 +95,6 @@ def sdk(mocker): return mocker.MagicMock(spec=SDKClient) -TEST_ID = "TEST_ID" - - @pytest.fixture def sdk_with_user(sdk): sdk.users.get_by_username.return_value = {"users": [{"userUid": TEST_ID}]} diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 000000000..e69de29bb From 0040474a919997e602426bc37394948b500bd1a6 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 31 Aug 2020 08:46:12 -0500 Subject: [PATCH 124/349] Output formats (#149) --- CHANGELOG.md | 6 +++++- src/code42cli/cmds/alert_rules.py | 3 +-- src/code42cli/cmds/legal_hold.py | 13 +++++------- src/code42cli/cmds/search/extraction.py | 10 ++------- src/code42cli/cmds/securitydata.py | 5 ++--- src/code42cli/output_formats.py | 11 +++++++++- tests/cmds/search/test_extraction.py | 4 ++-- tests/cmds/test_legal_hold.py | 28 ------------------------- tests/test_output_formats.py | 2 +- 9 files changed, 28 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b937901c..1806d69cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,9 +33,13 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - The `path` positional argument for bulk `generate-template` commands is now an option (`--p/-p`). - Below `search` subcommands accept argument `--format/-f` to display result in formats `csv`, `table`, `json`, `raw-json`: - Default output format is changed to `table` format from `raw-json`, returns a paginated response. - A predefined properties would be displayed by default, pass `--include-all` to view all non-nested top-level properties. + All properties would be displayed by default except when using `-f table`. + Pass `--include-all` when using `table` to view all non-nested top-level properties. - `code42 alerts search` - `code42 security-data search` + - `code42 security-data saved-search list` + - `code42 legal-hold list` + - `code42 alert-rules list` ### Added diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index 3accf756f..e6882f37e 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -79,8 +79,7 @@ def list_alert_rules(state, format=None): formatter = OutputFormatter(format, _HEADER_KEYS_MAP) selected_rules = _get_all_rules_metadata(state.sdk) if selected_rules: - for output in formatter.get_formatted_output(selected_rules): - echo(output) + formatter.echo_formatted_list(selected_rules) @alert_rules.command() diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index d39dea47d..418c5efdb 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -14,6 +14,7 @@ from code42cli.options import format_option from code42cli.options import OrderedGroup from code42cli.options import sdk_options +from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter from code42cli.util import format_string_list_to_columns @@ -75,8 +76,7 @@ def _list(state, format=None): formatter = OutputFormatter(format, _MATTER_KEYS_MAP) matters = _get_all_active_matters(state.sdk) if matters: - for output in formatter.get_formatted_output(matters): - echo(output) + formatter.echo_formatted_list(matters) @legal_hold.command() @@ -92,11 +92,9 @@ def _list(state, format=None): is_flag=True, help="View details of the preservation policy associated with the legal hold matter.", ) -@format_option @sdk_options() -def show(state, matter_id, include_inactive=False, include_policy=False, format=None): +def show(state, matter_id, include_inactive=False, include_policy=False): """Display details of a given legal hold matter.""" - formatter = OutputFormatter(format, _MATTER_KEYS_MAP) matter = _check_matter_is_accessible(state.sdk, matter_id) matter["creator_username"] = matter["creator"]["username"] matter = json.loads(matter.text) @@ -114,9 +112,8 @@ def show(state, matter_id, include_inactive=False, include_policy=False, format= member["user"]["username"] for member in memberships if not member["active"] ] - for output in formatter.get_formatted_output([matter]): - echo(output) - + formatter = OutputFormatter(OutputFormat.TABLE, _MATTER_KEYS_MAP) + formatter.echo_formatted_list([matter]) _print_matter_members(active_usernames, member_type="active") if include_inactive: diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index aa72fb8a7..5d1b02e09 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -94,16 +94,10 @@ def handle_response(response): total_events = len(events) handlers.TOTAL_EVENTS += total_events - def _format_output(): - return formatter.get_formatted_output(events) - if total_events > 10 or force_pager: - click.echo_via_pager(_format_output()) + click.echo_via_pager(formatter.get_formatted_output(events)) else: - for page in _format_output(): - click.echo(page, nl=False) - if formatter.output_format == OutputFormat.TABLE: - click.echo() + formatter.echo_formatted_list(events) # To make sure the extractor records correct timestamp event when `CTRL-C` is pressed. if total_events: diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 96219db9e..a68005a8b 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -166,7 +166,7 @@ def _get_saved_search_query(ctx, param, arg): "--format", type=click.Choice(SendToFileEventsOutputFormat(), case_sensitive=False), help="The output format of the result. Defaults to json format.", - default=SendToFileEventsOutputFormat.JSON, + default=SendToFileEventsOutputFormat.RAW, ) @@ -286,8 +286,7 @@ def _list(state, format=None): response = state.sdk.securitydata.savedsearches.get() saved_searches = response["searches"] if saved_searches: - for output in formatter.get_formatted_output(saved_searches): - echo(output) + formatter.echo_formatted_list(saved_searches) @saved_search.command() diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index c7420ade7..0ad9595d7 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -2,6 +2,8 @@ import io import json +import click + from code42cli.util import find_format_width from code42cli.util import format_to_table @@ -62,6 +64,13 @@ def get_formatted_output(self, output): for item in output: yield self._format_output(item) + def echo_formatted_list(self, output_list): + formatted_output = self.get_formatted_output(output_list) + for output in formatted_output: + click.echo(output, nl=False) + if self.output_format in [OutputFormat.TABLE]: + click.echo() + @property def _requires_list_output(self): return self.output_format in (OutputFormat.TABLE, OutputFormat.CSV) @@ -90,7 +99,7 @@ def to_table(output, header): def to_json(output): """Output is a single record""" - return "{}".format(json.dumps(output)) + return "{}\n".format(json.dumps(output)) def to_formatted_json(output): diff --git a/tests/cmds/search/test_extraction.py b/tests/cmds/search/test_extraction.py index b773c7058..c18b81186 100644 --- a/tests/cmds/search/test_extraction.py +++ b/tests/cmds/search/test_extraction.py @@ -43,7 +43,7 @@ def test_try_get_default_header_returns_none_when_is_table_and_told_to_include_a assert actual is None -def test_create_handlers_creates_handlers_that_pass_events_to_output_format( +def test_create_handlers_creates_handlers_that_pass_events_to_output_formatter( mocker, sdk, ): class TestExtractor(BaseExtractor): @@ -64,7 +64,7 @@ def _get_timestamp_from_item(self, item): http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) py42_response = Py42Response(http_response) handlers.handle_response(py42_response) - formatter.get_formatted_output.assert_called_once_with(events) + formatter.echo_formatted_list.assert_called_once_with(events) def test_send_to_handlers_creates_handlers_that_pass_events_to_logger( diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index 695d6ff9d..e8c8655bc 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -572,31 +572,3 @@ def test_list_with_csv_format_returns_no_response_when_response_is_empty( cli_state.sdk.legalhold.get_all_matters.return_value = empty_matters_response result = runner.invoke(cli, ["legal-hold", "list", "-f", "csv"], obj=cli_state) assert "Matter ID,Name,Description,Creator,Creation Date" not in result.output - - -def test_show_with_csv_format_option_returns_expected_format( - runner, - cli_state, - check_matter_accessible_success, - get_user_id_success, - active_and_inactive_legal_hold_memberships_response, -): - cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - active_and_inactive_legal_hold_memberships_response - ) - result = runner.invoke( - cli, ["legal-hold", "show", TEST_MATTER_ID, "-f", "csv"], obj=cli_state - ) - assert "legalHoldUid" in result.output - assert "name" in result.output - assert "description" in result.output - assert "active" in result.output - assert "creationDate" in result.output - assert "lastModified" in result.output - assert "creator" in result.output - assert "holdPolicyUid" in result.output - assert "creator_username" in result.output - assert "88888" in result.output - assert "Test_Matter" in result.output - comma_count = [c for c in result.output if c == ","] - assert len(comma_count) >= 13 diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index b01f4cda8..6609149b4 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -136,7 +136,7 @@ def test_to_table_when_not_given_header_creates_header_dynamically(): def test_to_json(): formatted_output = output_formats_module.to_json(TEST_DATA) - assert formatted_output == json.dumps(TEST_DATA) + assert formatted_output == "{}\n".format(json.dumps(TEST_DATA)) def test_to_formatted_json(): From 6c2510db679b2361dc8227345565f8636ae7d283 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 31 Aug 2020 09:15:21 -0500 Subject: [PATCH 125/349] Bugfix/fix broken pipes again (#150) * Handle broken pipe errors. * OSError instead of IOError --- src/code42cli/cmds/search/extraction.py | 3 +++ src/code42cli/errors.py | 3 +++ src/code42cli/logger.py | 23 ++++------------------- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index 5d1b02e09..647e39fe7 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -49,6 +49,9 @@ def _set_handlers(cursor_store, checkpoint_name): handlers.TOTAL_EVENTS = 0 def handle_error(exception): + if isinstance(exception, OSError): # let click handle it + raise + errors.ERRORED = True if hasattr(exception, "response") and hasattr(exception.response, "text"): message = "{}: {}".format(exception, exception.response.text) diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index d55f117c1..515f67366 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -126,6 +126,9 @@ def invoke(self, ctx): self.logger.log_verbose_error(self._original_args, err.response.request) raise LoggedCLIError("Problem making request to server.") + except OSError: + raise + except Exception: self.logger.log_verbose_error() raise LoggedCLIError("Unknown problem occurred.") diff --git a/src/code42cli/logger.py b/src/code42cli/logger.py index 352f86dbf..785368a89 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger.py @@ -9,6 +9,7 @@ from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper +from click.exceptions import ClickException from code42cli.cmds.search.enums import FileEventsOutputFormat from code42cli.util import get_url_parts @@ -38,28 +39,12 @@ def _init_logger(logger, handler, output_format): def handleError(record): """Override logger's `handleError` method to exit if an exception is raised while trying to - log, and replace stdout with devnull because if we're here it's usually because stdout has - been closed on us. + log, otherwise it would continue to gather and process events if the connection breaks but send + them nowhere. """ t, v, tb = sys.exc_info() if t == BrokenPipeError: - sys.stdout = open(os.devnull) - sys.exit() - - -def get_logger_for_stdout(name_suffix="main", formatter=None): - logger = logging.getLogger("code42_stdout_{}".format(name_suffix)) - if logger_has_handlers(logger): - return logger - - with logger_deps_lock: - if not logger_has_handlers(logger): - handler = logging.StreamHandler(sys.stdout) - handler.handleError = handleError - formatter = formatter or _get_standard_formatter() - logger.setLevel(logging.INFO) - return add_handler_to_logger(logger, handler, formatter) - return logger + raise ClickException("Network connection broken while sending results.") def get_logger_for_server(hostname, protocol, output_format): From cf3190fd7e77fc2824a44b1b4e87ccc5f6b95fdb Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 31 Aug 2020 09:57:29 -0500 Subject: [PATCH 126/349] remove nc/powershell examples and replace with send-to in docs/readme (#151) --- README.md | 32 +++++++------------------------- docs/userguides/siemexample.md | 6 +++--- src/code42cli/options.py | 2 +- 3 files changed, 11 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index cb63a796d..40868b67d 100644 --- a/README.md +++ b/README.md @@ -132,37 +132,19 @@ To write events to a file, just redirect your output: code42 security-data search -b 2020-02-02 > filename.txt ``` -To send events to an external server using `netcat` on Linux/Mac: +To send events to an external server, use the `send-to` command, which behaves exactly the same as `search` but sends +results to an external server instead of to stdout: -UDP: -```bash -code42 security-data search -b 10d | nc -u syslog.company.com 514 -``` +The default port (if none is specified on the address) is the standard syslog port 514, and default protocol is UDP: -TCP: ```bash -code42 security-data search -b 10d | nc server.company.com 8080 +code42 security-data send-to 10.10.10.42 -b 1d ``` -Using `powershell` on Windows: +Results can also be sent over TCP to any port by using the `-p/--protocol` flag and adding a port to the address argument: -UDP: -```powershell -# set up connection -$Connection = New-Object System.Net.Sockets.UDPClient("syslog.company.com",514) - -# pipe code42 output through connection -code42 security-data search -b 10d | foreach {$Message = [Text.Encoding]::UTF8.GetBytes($_); $Connection.Send($Message, $Message.Length)} -``` - -TCP: -```powershell -# set up connection -$Connection = New-Object System.Net.Sockets.TcpClient("127.0.0.1","65432") -$Writer = New-Object System.IO.StreamWriter($Connection.GetStream()) - -# pipe code42 output through connection -code42 security-data search -b 10d | foreach { $Writer.WriteLine($_); $Writer.Flush() } +```bash +code42 security-data send-to 10.10.10.42:8080 -p TCP -b 1d ``` Note: For more complex requirements when sending to an external server (SSL, special formatting, etc.), use a dedicated diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index 49a6e94b9..d8e8f773e 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -20,14 +20,14 @@ scheduled job or run ad-hoc queries. Learn more about [searching](../commands/se ### Run a query as a scheduled job Use your favorite scheduling tool, such as cron or Windows Task Scheduler, to run a query on a regular basis. Specify -the profile to use by including `--profile`. An example using `netcat` to forward only the new file event data since the previous request to an external syslog server: +the profile to use by including `--profile`. An example using the `send-to` command to forward only the new file event data since the previous request to an external syslog server: ```bash -code42 security-data search --profile profile1 -c syslog_sender | nc syslog.example.com 514 +code42 security-data send-to syslog.example.com:514 -p UDP --profile profile1 -c syslog_sender ``` An example to send to the syslog server only the new alerts that meet the filter criteria since the previous request: ```bash -code42 alerts send-to "https://syslog.example.com:514" -p UDP --profile profile1 --rule-name “Source code exfiltration” --state OPEN -i +code42 alerts send-to syslog.example.com:514 -p UDP --profile profile1 --rule-name “Source code exfiltration” --state OPEN -i ``` As a best practice, use a separate profile when executing a scheduled task. Using separate profiles can help prevent accidental updates to your stored checkpoints, for example, by adding `--use-checkpoint` to adhoc queries. diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 00315d14c..5a03c37f2 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -163,7 +163,7 @@ def server_options(f): "--protocol", type=click.Choice(ServerProtocol(), case_sensitive=False), default=ServerProtocol.UDP, - help="Protocol used to send logs to server.", + help="Protocol used to send logs to server. Defaults to UDP", ) f = hostname_arg(f) f = protocol_option(f) From 16f27c4a044e5c4e975a6b7a34d768ae30cc4198 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 31 Aug 2020 10:18:35 -0500 Subject: [PATCH 127/349] clarify send-to default format in readme (#152) * remove nc/powershell examples and replace with send-to in docs/readme * clarify output format default difference * style --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 40868b67d..642a5cfe5 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,8 @@ To write events to a file, just redirect your output: code42 security-data search -b 2020-02-02 > filename.txt ``` -To send events to an external server, use the `send-to` command, which behaves exactly the same as `search` but sends -results to an external server instead of to stdout: +To send events to an external server, use the `send-to` command, which behaves the same as `search` except for defaulting +to `RAW-JSON` output and sending results to an external server instead of to stdout: The default port (if none is specified on the address) is the standard syslog port 514, and default protocol is UDP: From eba3af31423b8523db139fabbb56be8405bc9f28 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 31 Aug 2020 12:24:27 -0500 Subject: [PATCH 128/349] handle detection list removal errors (#153) * handle detectionlist removal errors * also handle alert-rules removal error * style * add tests * style --- src/code42cli/cmds/alert_rules.py | 8 +++++- src/code42cli/cmds/departing_employee.py | 10 ++++++- src/code42cli/cmds/high_risk_employee.py | 11 +++++++- tests/cmds/test_alert_rules.py | 31 +++++++++++++++++++++ tests/cmds/test_departing_employee.py | 34 ++++++++++++++++++++++++ tests/cmds/test_high_risk_employee.py | 34 ++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index e6882f37e..880548e77 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -2,6 +2,7 @@ import click from click import echo +from py42.exceptions import Py42BadRequestError from py42.util import format_json from code42cli import PRODUCT_NAME @@ -68,7 +69,12 @@ def add_user(state, rule_id, username): @sdk_options() def remove_user(state, rule_id, username): """Remove a user from an alert rule.""" - _remove_user(state.sdk, rule_id, username) + try: + _remove_user(state.sdk, rule_id, username) + except Py42BadRequestError: + raise Code42CLIError( + "User {} is not currently assigned to rule-id {}.".format(username, rule_id) + ) @alert_rules.command("list") diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index aca67cd18..bf8ce5a32 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -1,4 +1,5 @@ import click +from py42.exceptions import Py42NotFoundError from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process @@ -46,7 +47,14 @@ def add(state, username, cloud_alias, departure_date, notes): @sdk_options() def remove(state, username): """Remove a user from the departing-employee detection list.""" - _remove_departing_employee(state.sdk, username) + try: + _remove_departing_employee(state.sdk, username) + except Py42NotFoundError: + raise Code42CLIError( + "User {} is not currently on the departing-employee detection list.".format( + username + ) + ) @departing_employee.group(cls=OrderedGroup) diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 9cb66936f..eeb41798c 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -1,5 +1,6 @@ import click from py42.clients.detectionlists import RiskTags +from py42.exceptions import Py42NotFoundError from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process @@ -11,6 +12,7 @@ from code42cli.cmds.detectionlists.options import notes_option from code42cli.cmds.detectionlists.options import username_arg from code42cli.cmds.shared import get_user_id +from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.file_readers import read_flat_file_arg from code42cli.options import OrderedGroup @@ -48,7 +50,14 @@ def add(state, username, cloud_alias, risk_tag, notes): @sdk_options() def remove(state, username): """Remove a user from the high risk employees detection list.""" - _remove_high_risk_employee(state.sdk, username) + try: + _remove_high_risk_employee(state.sdk, username) + except Py42NotFoundError: + raise Code42CLIError( + "User {} is not currently on the high-risk-employee detection list.".format( + username + ) + ) @high_risk_employee.command() diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index fcfb5bb73..cfb0ad67e 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -2,6 +2,7 @@ import logging import pytest +from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42InternalServerError from py42.exceptions import Py42InvalidRuleOperationError from py42.response import Py42Response @@ -55,6 +56,17 @@ def side_effect(*args, **kwargs): return side_effect +def get_user_not_on_alert_rule_side_effect(mocker): + def side_effect(*args, **kwargs): + err = mocker.MagicMock(spec=HTTPError) + resp = mocker.MagicMock(spec=Response) + resp.text = "TEST_ERR" + err.response = resp + raise Py42BadRequestError(err) + + return side_effect + + def create_invalid_rule_type_side_effect(mocker): def side_effect(*args, **kwargs): err = mocker.MagicMock(spec=HTTPError) @@ -312,3 +324,22 @@ def test_list_cmd_formats_to_csv_when_format_is_passed(runner, cli_state): assert "ruleSource" in result.output assert "name" in result.output assert "severity" in result.output + + +def test_remove_when_user_not_on_rule_raises_expected_error(runner, cli_state, mocker): + cli_state.sdk.alerts.rules.remove_user.side_effect = get_user_not_on_alert_rule_side_effect( + mocker + ) + test_username = "test@example.com" + test_rule_id = "101010" + result = runner.invoke( + cli, + ["alert-rules", "remove-user", "-u", test_username, "--rule-id", test_rule_id], + obj=cli_state, + ) + assert ( + "User {} is not currently assigned to rule-id {}.".format( + test_username, test_rule_id + ) + in result.output + ) diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 3e1b3009c..fb50b2a2a 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -1,3 +1,7 @@ +from py42.exceptions import Py42NotFoundError +from requests import HTTPError +from requests import Request +from requests import Response from tests.cmds.conftest import thread_safe_side_effect from tests.conftest import TEST_ID @@ -5,6 +9,18 @@ from code42cli.main import cli +def get_user_not_on_departing_employee_list_side_effect(mocker): + def side_effect(*args, **kwargs): + err = mocker.MagicMock(spec=HTTPError) + resp = mocker.MagicMock(spec=Response) + resp.text = "TEST_ERR" + err.response = resp + err.response.request = mocker.MagicMock(spec=Request) + raise Py42NotFoundError(err) + + return side_effect + + def test_add_departing_employee_when_given_cloud_alias_adds_alias( runner, cli_state_with_user ): @@ -192,3 +208,21 @@ def test_add_departing_employee_when_invalid_date_format_validation_raises_error assert ( "Invalid value for '--departure-date': invalid datetime format" in result.output ) + + +def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( + mocker, runner, cli_state +): + cli_state.sdk.detectionlists.departing_employee.remove.side_effect = get_user_not_on_departing_employee_list_side_effect( + mocker + ) + test_username = "test@example.com" + result = runner.invoke( + cli, ["departing-employee", "remove", test_username], obj=cli_state + ) + assert ( + "User {} is not currently on the departing-employee detection list.".format( + test_username + ) + in result.output + ) diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index cd76d8c7c..2228b7f71 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -1,3 +1,7 @@ +from py42.exceptions import Py42NotFoundError +from requests import HTTPError +from requests import Request +from requests import Response from tests.cmds.conftest import TEST_EMPLOYEE from tests.cmds.conftest import thread_safe_side_effect from tests.conftest import TEST_ID @@ -7,6 +11,18 @@ _NAMESPACE = "code42cli.cmds.high_risk_employee" +def get_user_not_on_high_risk_employee_list_side_effect(mocker): + def side_effect(*args, **kwargs): + err = mocker.MagicMock(spec=HTTPError) + resp = mocker.MagicMock(spec=Response) + resp.text = "TEST_ERR" + err.response = resp + err.response.request = mocker.MagicMock(spec=Request) + raise Py42NotFoundError(err) + + return side_effect + + def test_add_high_risk_employee_adds(runner, cli_state_with_user): runner.invoke( cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user @@ -219,3 +235,21 @@ def test_bulk_remove_risk_tags_uses_expected_arguments(runner, cli_state, mocker {"username": "test@example.com", "tag": "tag1"}, {"username": "test2@example.com", "tag": "tag2"}, ] + + +def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( + mocker, runner, cli_state +): + cli_state.sdk.detectionlists.high_risk_employee.remove.side_effect = get_user_not_on_high_risk_employee_list_side_effect( + mocker + ) + test_username = "test@example.com" + result = runner.invoke( + cli, ["high-risk-employee", "remove", test_username], obj=cli_state + ) + assert ( + "User {} is not currently on the high-risk-employee detection list.".format( + test_username + ) + in result.output + ) From d571763918c5dfa9dfbd96c1af3e3cc5bc723e41 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 31 Aug 2020 12:43:49 -0500 Subject: [PATCH 129/349] Bumps and CL formatting / fixing (#154) --- CHANGELOG.md | 16 +++++++++++++--- src/code42cli/__version__.py | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1806d69cb..28ac3e507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.0.0 - 2020-08-31 ### Fixed - Bug where `code42 legal-hold show` would error when terminal was too small. + - Fixed bug in `departing_employee bulk add` command that allowed invalid dates to be passed without validation. ### Changed +- The follow commands now print a nicer error message when trying to remove a user who is not on the list: + - `code42 departing-employee remove` + - `code42 high-risk-employee remove` + - `code42 alert-rules remove-user` + - `-i` (`--incremental`) has been removed, use `-c` (`--use-checkpoint`) with a string name for the checkpoint instead. - The code42cli has been migrated to the [click](https://click.palletsprojects.com) framework. This brings: @@ -31,6 +37,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta each time the CLI is run with a profile configured this way, as it is not recommended. - The `path` positional argument for bulk `generate-template` commands is now an option (`--p/-p`). + - Below `search` subcommands accept argument `--format/-f` to display result in formats `csv`, `table`, `json`, `raw-json`: - Default output format is changed to `table` format from `raw-json`, returns a paginated response. All properties would be displayed by default except when using `-f table`. @@ -44,19 +51,22 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added - `--or-query` option added to `security-data search` and `alerts search` commands which combines the provided filter arguments into an 'OR' query instead of the default 'AND' query. + - `--password` option added to `profile create` and `profile update` commands, enabling creating profiles while bypassing the interactive password prompt. + - Profiles can now save multiple alert and file event checkpoints. The name of the checkpoint to be used for a given query should be passed to `-c` (`--use-checkpoint`). + - `-y/--assume-yes` option added to `profile delete` and `profile delete-all` commands to not require interactive prompt. + - Below subcommands accept argument `--format/-f` to display result in formats `csv`, `table`, `json`, `formatted-json`: - `code42 alert-rules list` - `code42 legal-hold list` - `code42 legal-hold show` - `code42 security-data saved-search list` -- Re-added `send-to` command to `alerts` and `security-data` that accepts a host address and a `--protocol` option with choices UDP or TCP. ### Removed -- The `write-to` and `send-to` commands on `security-data` and `alerts` command groups. +- The `write-to` command for `security-data` and `alerts` command groups. ## 0.7.3 - 2020-06-23 diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 846359f62..5becc17c0 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.0.0b2" +__version__ = "1.0.0" From ef4c8d91bbd2f655d77aba6ef5eb576be9066501 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 3 Sep 2020 13:57:16 -0500 Subject: [PATCH 130/349] Handle updating cloud aliases when adding to a list (#155) --- CHANGELOG.md | 7 +++ src/code42cli/cmds/detectionlists/__init__.py | 17 ++++++ src/code42cli/cmds/detectionlists/options.py | 3 +- tests/cmds/detectionlists/__init__.py | 0 tests/cmds/detectionlists/test_init.py | 58 +++++++++++++++++++ 5 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/cmds/detectionlists/__init__.py create mode 100644 tests/cmds/detectionlists/test_init.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ac3e507..fe9b0ded6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Changed + +- Now, when adding a cloud alias to a detection list user, such as during `departing-employee add`, it will remove the existing cloud alias if one exists. + - Before, it would error and the cloud alias would not get added. + ## 1.0.0 - 2020-08-31 ### Fixed diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index f05aa2ab1..64bf7e044 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -12,10 +12,27 @@ def update_user(sdk, username, cloud_alias=None, risk_tag=None, notes=None): notes (str or unicode): Notes about the user. """ user_id = get_user_id(sdk, username) + _update_cloud_alias(sdk, user_id, cloud_alias) + _update_risk_tags(sdk, username, risk_tag) + _update_notes(sdk, user_id, notes) + + +def _update_cloud_alias(sdk, user_id, cloud_alias): if cloud_alias: + profile = sdk.detectionlists.get_user_by_id(user_id) + cloud_aliases = profile.data.get("cloudUsernames") or [] + for alias in cloud_aliases: + if alias != profile["userName"]: + sdk.detectionlists.remove_user_cloud_alias(user_id, alias) sdk.detectionlists.add_user_cloud_alias(user_id, cloud_alias) + + +def _update_risk_tags(sdk, username, risk_tag): if risk_tag: add_risk_tags(sdk, username, risk_tag) + + +def _update_notes(sdk, user_id, notes): if notes: sdk.detectionlists.update_user_notes(user_id, notes) diff --git a/src/code42cli/cmds/detectionlists/options.py b/src/code42cli/cmds/detectionlists/options.py index 00c9a3892..da538e934 100644 --- a/src/code42cli/cmds/detectionlists/options.py +++ b/src/code42cli/cmds/detectionlists/options.py @@ -5,6 +5,7 @@ "--cloud-alias", help="If the employee has an email alias other than their Code42 username " "that they use for cloud services such as Google Drive, OneDrive, or Box, " - "add and monitor the alias.", + "add and monitor the alias. WARNING: Adding a cloud alias will override any " + "existing cloud alias for this user.", ) notes_option = click.option("--notes", help="Optional notes about the employee.") diff --git a/tests/cmds/detectionlists/__init__.py b/tests/cmds/detectionlists/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py new file mode 100644 index 000000000..9e50887c8 --- /dev/null +++ b/tests/cmds/detectionlists/test_init.py @@ -0,0 +1,58 @@ +import pytest +from py42.response import Py42Response +from requests import Response + +from code42cli.cmds.detectionlists import update_user + + +MOCK_USER_ID = "USER-ID" +MOCK_USER_NAME = "test@example.com" +MOCK_ALIAS = "alias@example" +MOCK_USER_PROFILE_RESPONSE = """ +{{ + "type$": "USER_V2", + "tenantId": "TENANT-ID", + "userId": "{0}", + "userName": "{1}", + "displayName": "Test", + "notes": "Notes", + "cloudUsernames": ["{2}", "{1}"], + "riskFactors": ["HIGH_IMPACT_EMPLOYEE"] +}} +""".format( + MOCK_USER_ID, MOCK_USER_NAME, MOCK_ALIAS +) + + +@pytest.fixture +def user_response_with_cloud_aliases(mocker): + response = mocker.MagicMock(spec=Response) + response.text = MOCK_USER_PROFILE_RESPONSE + return Py42Response(response) + + +@pytest.fixture +def mock_user_id(mocker): + mock = mocker.patch("code42cli.cmds.detectionlists.get_user_id") + mock.return_value = MOCK_USER_ID + return mock + + +def test_update_user_when_given_cloud_alias_add_cloud_alias( + sdk, user_response_with_cloud_aliases, mock_user_id +): + sdk.detectionlists.get_user_by_id.return_value = user_response_with_cloud_aliases + update_user(sdk, MOCK_USER_NAME, cloud_alias="new.alias@exaple.com") + sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( + MOCK_USER_ID, "new.alias@exaple.com" + ) + + +def test_update_user_when_given_cloud_alias_first_removes_old_alias( + sdk, user_response_with_cloud_aliases, mock_user_id +): + sdk.detectionlists.get_user_by_id.return_value = user_response_with_cloud_aliases + update_user(sdk, MOCK_USER_NAME, cloud_alias="new.alias@exaple.com") + sdk.detectionlists.remove_user_cloud_alias.assert_called_once_with( + MOCK_USER_ID, MOCK_ALIAS + ) From a2d295b0043f6d79faed0ff9468663e6662c7989 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 11 Sep 2020 16:35:27 -0500 Subject: [PATCH 131/349] Correct doc str (#157) * Correct doc str * Fix doc in other places --- src/code42cli/password.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/code42cli/password.py b/src/code42cli/password.py index 6b3492aa7..badd7c2c9 100644 --- a/src/code42cli/password.py +++ b/src/code42cli/password.py @@ -7,7 +7,7 @@ def get_stored_password(profile): - """Gets your currently stored password for the given profile name.""" + """Gets your currently stored password for the given profile.""" service_name = _get_keyring_service_name(profile.name) return keyring.get_password(service_name, profile.username) @@ -18,7 +18,7 @@ def get_password_from_prompt(): def set_password(profile, new_password): - """Sets your password for the given profile name.""" + """Sets your password for the given profile.""" service_name = _get_keyring_service_name(profile.name) uses_file_storage = keyring.get_keyring().priority < 1 if uses_file_storage and not _prompt_for_alternative_store(): @@ -28,7 +28,7 @@ def set_password(profile, new_password): def delete_password(profile): - """Deletes password for the given profile name.""" + """Deletes password for the given profile.""" service_name = _get_keyring_service_name(profile.name) keyring.delete_password(service_name, profile.username) From 86b2596505d924729951757e126fb3cb366e98eb Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 18 Sep 2020 07:43:37 -0500 Subject: [PATCH 132/349] Add CLA workflow (#156) * add workflow * style fix * point cla action to code42-cla repo * Add CLA note to contributing guide --- .github/workflows/cla.yml | 33 +++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 4 ++++ 2 files changed, 37 insertions(+) create mode 100644 .github/workflows/cla.yml diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 000000000..156f39f33 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,33 @@ +name: "CLA Assistant" +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened,closed,synchronize] + +jobs: + CLAssistant: + runs-on: ubuntu-latest + steps: + - name: "CLA Assistant" + if: (github.event.comment.body == 'recheckcla' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + # Alpha Release + uses: cla-assistant/github-action@v2.0.1-alpha + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # the below token should have repo scope and must be manually added by you in the repository's secret + PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} + with: + path-to-signatures: '.cla_signatures.json' + path-to-cla-document: 'https://code42.github.io/code42-cla/Code42_Individual_Contributor_License_Agreement' + # branch should not be protected + branch: 'master' + allowlist: alang13,unparalleled-js,kiran-chaudhary,timabrmsn,ceciliastevens,DiscoRiver,annie-payseur,amoravec,patelsagar192 + + #below are the optional inputs - If the optional inputs are not given, then default values will be taken + #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) + remote-repository-name: code42-cla + #create-file-commit-message: 'For example: Creating file for storing CLA Signatures' + #signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo' + #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign' + #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8a11d674..3c1648b03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -188,4 +188,8 @@ Document all notable consumer-affecting changes in CHANGELOG.md per principles a When you're satisfied with your changes, open a PR and fill out the pull request template file. We recommend prefixing the name of your branch and/or PR title with `bugfix`, `chore`, or `feature` to help quickly categorize your change. Your unit tests and other checks will run against all supported python versions when you do this. +For contributions from non-Code42 employees, we require you to agree to our [Contributor License Agreement](https://code42.github.io/code42-cla/Code42_Individual_Contributor_License_Agreement). + +On submission of your first PR, a GitHub action will run requiring you to reply in a comment with your affirmation of the CLA before the PR will be able to be merged. + A team member should get in contact with you shortly to help merge your PR to completion and get it ready for a release! From e8846ad693113d866ba253229a581f3314e6d947 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Fri, 18 Sep 2020 15:48:57 -0500 Subject: [PATCH 133/349] Update index.md (#159) --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 93e62d4ab..761969c7f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ list. ## Requirements To use the Code42 CLI, you must have: -* A Code42 Diamond or Platinum product plan +* A [Code42 product plan](https://code42.com/r/support/product-plans) that supports the feature or functionality for your use case * Endpoint monitoring enabled in the Code42 console * Python version 3.5 and later installed From 82a9e15b52df130e3bc908129bbb1d8c2fee6047 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Sep 2020 21:12:04 +0000 Subject: [PATCH 134/349] Show correct profile name when resetting default (#162) * Show correct profile name when resetting default * Fix test names * Add new failing test * Add new test --- src/code42cli/cmds/profile.py | 5 +++-- tests/cmds/test_profile.py | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 4dac6a9d6..4d2f2de51 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -102,8 +102,8 @@ def reset_pw(profile_name): Change the stored password for a profile. Only affects what's stored in the local profile, does not make any changes to the Code42 user account.""" password = getpass() - _set_pw(profile_name, password) - echo("Password updated for profile '{}'".format(profile_name)) + profile_name_saved = _set_pw(profile_name, password) + echo("Password updated for profile '{}'.".format(profile_name_saved)) @profile.command("list") @@ -171,3 +171,4 @@ def _set_pw(profile_name, password): secho("Password not stored!", bold=True) raise cliprofile.set_password(password, c42profile.name) + return c42profile.name diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 110a04615..cb55a7edd 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -390,7 +390,7 @@ def test_delete_all_deletes_all_existing_profiles( mock_cliprofile_namespace.delete_profile.assert_any_call("test2") -def test_prompt_for_password_reset_if_credentials_valid_password_saved( +def test_reset_pw_if_credentials_valid_password_saved( runner, mocker, user_agreement, mock_verify, mock_cliprofile_namespace ): mock_verify.return_value = True @@ -401,7 +401,7 @@ def test_prompt_for_password_reset_if_credentials_valid_password_saved( ) -def test_prompt_for_password_reset_if_credentials_invalid_password_not_saved( +def test_reset_pw_if_credentials_invalid_password_not_saved( runner, user_agreement, mock_verify, mock_cliprofile_namespace ): mock_verify.side_effect = Code42CLIError("Invalid credentials for user") @@ -410,6 +410,20 @@ def test_prompt_for_password_reset_if_credentials_invalid_password_not_saved( assert not mock_cliprofile_namespace.set_password.call_count +def test_reset_pw_uses_default_profile_when_not_given_one( + runner, mocker, user_agreement, mock_verify, mock_cliprofile_namespace +): + mock_verify.return_value = True + mock_cliprofile_namespace.profile_exists.return_value = False + mock_profile = create_mock_profile("one") + mock_cliprofile_namespace.get_profile.return_value = mock_profile + res = runner.invoke(cli, ["profile", "reset-pw"]) + mock_cliprofile_namespace.set_password.assert_called_once_with( + "newpassword", mocker.ANY + ) + assert "Password updated for profile 'one'." in res.output + + def test_list_profiles(runner, mock_cliprofile_namespace): profiles = [ create_mock_profile("one"), From 272e1b04b0c7dea322ff7dfdede0ebd26438065c Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 25 Sep 2020 08:56:32 -0500 Subject: [PATCH 135/349] Feature/advanced query from file (#161) * add workflow * style fix * point cla action to code42-cla repo * Add CLA note to contributing guide * style * style2 * update changelog * note checkpoint update in changelog * style * make filename argument require `@` prefix * reword build query exception --- CHANGELOG.md | 4 + src/code42cli/click_ext/__init__.py | 0 src/code42cli/click_ext/groups.py | 110 ++++++++++++++++ src/code42cli/click_ext/options.py | 36 +++++ src/code42cli/click_ext/types.py | 18 +++ src/code42cli/cmds/alert_rules.py | 2 +- src/code42cli/cmds/alerts.py | 16 +-- src/code42cli/cmds/departing_employee.py | 2 +- src/code42cli/cmds/high_risk_employee.py | 2 +- src/code42cli/cmds/legal_hold.py | 2 +- src/code42cli/cmds/search/options.py | 45 ++++--- src/code42cli/cmds/securitydata.py | 10 +- src/code42cli/errors.py | 89 ------------- src/code42cli/main.py | 2 +- src/code42cli/options.py | 48 ------- tests/cmds/test_alerts.py | 142 ++++++++++++++------ tests/cmds/test_securitydata.py | 161 ++++++++++++++++------- 17 files changed, 427 insertions(+), 262 deletions(-) create mode 100644 src/code42cli/click_ext/__init__.py create mode 100644 src/code42cli/click_ext/groups.py create mode 100644 src/code42cli/click_ext/options.py create mode 100644 src/code42cli/click_ext/types.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9b0ded6..945e296c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Changed +- The `--advanced-query` option on `alerts search` and `security-data (search|send-to)` commands has been updated: + - It can now accept the query as a JSON string or as the path to a file containing the JSON query. + - It can be used with the `--use-checkpoint/-c` option. + - Now, when adding a cloud alias to a detection list user, such as during `departing-employee add`, it will remove the existing cloud alias if one exists. - Before, it would error and the cloud alias would not get added. diff --git a/src/code42cli/click_ext/__init__.py b/src/code42cli/click_ext/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py new file mode 100644 index 000000000..b366aa03d --- /dev/null +++ b/src/code42cli/click_ext/groups.py @@ -0,0 +1,110 @@ +import difflib +import re +from collections import OrderedDict + +import click +from py42.exceptions import Py42ForbiddenError +from py42.exceptions import Py42HTTPError +from py42.exceptions import Py42InvalidRuleOperationError +from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError +from py42.exceptions import Py42UserAlreadyAddedError + +from code42cli.errors import Code42CLIError +from code42cli.errors import LoggedCLIError +from code42cli.errors import UserDoesNotExistError +from code42cli.logger import get_main_cli_logger + +_DIFFLIB_CUT_OFF = 0.6 + + +class ExceptionHandlingGroup(click.Group): + """A `click.Group` subclass to add custom exception handling.""" + + logger = get_main_cli_logger() + _original_args = None + + def make_context(self, info_name, args, parent=None, **extra): + + # grab the original command line arguments for logging purposes + self._original_args = " ".join(args) + + return super().make_context(info_name, args, parent=parent, **extra) + + def invoke(self, ctx): + try: + return super().invoke(ctx) + + except click.UsageError as err: + self._suggest_cmd(err) + + except LoggedCLIError: + raise + + except Code42CLIError as err: + self.logger.log_error(str(err)) + raise + + except click.ClickException: + raise + + except click.exceptions.Exit: + raise + + except ( + UserDoesNotExistError, + Py42UserAlreadyAddedError, + Py42InvalidRuleOperationError, + Py42LegalHoldNotFoundOrPermissionDeniedError, + ) as err: + self.logger.log_error(err) + raise Code42CLIError(str(err)) + + except Py42ForbiddenError as err: + self.logger.log_verbose_error(self._original_args, err.response.request) + raise LoggedCLIError( + "You do not have the necessary permissions to perform this task. " + "Try using or creating a different profile." + ) + + except Py42HTTPError as err: + self.logger.log_verbose_error(self._original_args, err.response.request) + raise LoggedCLIError("Problem making request to server.") + + except OSError: + raise + + except Exception: + self.logger.log_verbose_error() + raise LoggedCLIError("Unknown problem occurred.") + + @staticmethod + def _suggest_cmd(usage_err): + """Handles fuzzy suggestion of commands that are close to the bad command entered.""" + if usage_err.message is not None: + match = re.match("No such command '(.*)'.", usage_err.message) + if match: + bad_arg = match.groups()[0] + available_commands = list(usage_err.ctx.command.commands.keys()) + suggested_commands = difflib.get_close_matches( + bad_arg, available_commands, cutoff=_DIFFLIB_CUT_OFF + ) + if not suggested_commands: + raise usage_err + usage_err.message = "No such command '{}'. Did you mean {}?".format( + bad_arg, " or ".join(suggested_commands) + ) + raise usage_err + + +class OrderedGroup(click.Group): + """A `click.Group` subclass that uses an `OrderedDict` to store commands so the help text lists + them in the order they were defined/added to the group. + """ + + def __init__(self, name=None, commands=None, **attrs): + super().__init__(name, commands, **attrs) + # the registered subcommands by their exported names. + self.commands = commands or OrderedDict() + + def list_commands(self, ctx): + return self.commands diff --git a/src/code42cli/click_ext/options.py b/src/code42cli/click_ext/options.py new file mode 100644 index 000000000..8964db96d --- /dev/null +++ b/src/code42cli/click_ext/options.py @@ -0,0 +1,36 @@ +import click + + +def incompatible_with(incompatible_opts): + """Factory for creating custom `click.Option` subclasses that enforce incompatibility with the + option strings passed to this function. + """ + + if isinstance(incompatible_opts, str): + incompatible_opts = [incompatible_opts] + + class IncompatibleOption(click.Option): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + # if None it means we're in autocomplete mode and don't want to validate + if ctx.obj is not None: + found_incompatible = ", ".join( + [ + "--{}".format(opt.replace("_", "-")) + for opt in opts + if opt in incompatible_opts + ] + ) + if self.name in opts and found_incompatible: + name = self.name.replace("_", "-") + raise click.BadOptionUsage( + option_name=self.name, + message="--{} can't be used with: {}".format( + name, found_incompatible + ), + ) + return super().handle_parse_result(ctx, opts, args) + + return IncompatibleOption diff --git a/src/code42cli/click_ext/types.py b/src/code42cli/click_ext/types.py new file mode 100644 index 000000000..f2ba5ba25 --- /dev/null +++ b/src/code42cli/click_ext/types.py @@ -0,0 +1,18 @@ +import click + + +class FileOrString(click.File): + """Declares a parameter to be a file (if the argument begins with `@`), otherwise accepts it as + a string. + """ + + def __init__(self): + super().__init__("r") + + def convert(self, value, param, ctx): + if value.startswith("@") or value == "-": + value = value.lstrip("@") + file = super().convert(value, param, ctx) + return file.read() + else: + return value diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index 880548e77..de05b58d2 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -8,11 +8,11 @@ from code42cli import PRODUCT_NAME from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process +from code42cli.click_ext.groups import OrderedGroup from code42cli.cmds.shared import get_user_id from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.options import format_option -from code42cli.options import OrderedGroup from code42cli.options import sdk_options from code42cli.output_formats import OutputFormatter diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index a844f5940..9fddce042 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -7,6 +7,7 @@ from py42.sdk.queries.alerts.filters import RuleType from py42.sdk.queries.alerts.filters import Severity +import code42cli.click_ext.groups import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt import code42cli.errors as errors @@ -155,7 +156,7 @@ def alert_options(f): return f -@click.group(cls=opt.OrderedGroup) +@click.group(cls=code42cli.click_ext.groups.OrderedGroup) @opt.sdk_options(hidden=True) def alerts(state): """Tools for getting alert data.""" @@ -177,13 +178,12 @@ def _call_extractor( extractor = _get_alert_extractor(cli_state.sdk, handlers) extractor.use_or_query = or_query if advanced_query: - extractor.extract_advanced(advanced_query) - else: - if begin or end: - cli_state.search_filters.append( - ext.create_time_range_filter(f.DateObserved, begin, end) - ) - extractor.extract(*cli_state.search_filters) + cli_state.search_filters = advanced_query + if begin or end: + cli_state.search_filters.append( + ext.create_time_range_filter(f.DateObserved, begin, end) + ) + extractor.extract(*cli_state.search_filters) @alerts.command() diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index bf8ce5a32..7d52087c3 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -3,6 +3,7 @@ from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process +from code42cli.click_ext.groups import OrderedGroup from code42cli.cmds.detectionlists import update_user from code42cli.cmds.detectionlists.options import cloud_alias_option from code42cli.cmds.detectionlists.options import notes_option @@ -11,7 +12,6 @@ from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.file_readers import read_flat_file_arg -from code42cli.options import OrderedGroup from code42cli.options import sdk_options diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index eeb41798c..dc5708a1f 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -4,6 +4,7 @@ from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process +from code42cli.click_ext.groups import OrderedGroup from code42cli.cmds.detectionlists import add_risk_tags as _add_risk_tags from code42cli.cmds.detectionlists import handle_list_args from code42cli.cmds.detectionlists import remove_risk_tags as _remove_risk_tags @@ -15,7 +16,6 @@ from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.file_readers import read_flat_file_arg -from code42cli.options import OrderedGroup from code42cli.options import sdk_options risk_tag_option = click.option( diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 418c5efdb..76c4bbc76 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -8,11 +8,11 @@ from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process +from code42cli.click_ext.groups import OrderedGroup from code42cli.cmds.shared import get_user_id from code42cli.errors import UserNotInLegalHoldError from code42cli.file_readers import read_csv_arg from code42cli.options import format_option -from code42cli.options import OrderedGroup from code42cli.options import sdk_options from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index 49eee0f0c..a7fc74de2 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -3,11 +3,13 @@ from datetime import timezone import click +from py42.sdk.queries.query_filter import FilterGroup +from code42cli.click_ext.options import incompatible_with +from code42cli.click_ext.types import FileOrString from code42cli.date_helper import parse_max_timestamp from code42cli.date_helper import parse_min_timestamp from code42cli.logger import get_main_cli_logger -from code42cli.options import incompatible_with logger = get_main_cli_logger() @@ -59,18 +61,6 @@ def callback(ctx, param, arg): return callback -def validate_advanced_query_is_json(ctx, param, arg): - if arg is None: - return - try: - json.loads(arg) - return arg - except json.JSONDecodeError: - raise click.ClickException( - "Failed to parse advanced query, must be a valid json string." - ) - - AdvancedQueryAndSavedSearchIncompatible = incompatible_with( ["advanced_query", "saved_search"] ) @@ -127,6 +117,21 @@ def handle_parse_result(self, ctx, opts, args): return super().handle_parse_result(ctx, opts, args) +def _parse_query_from_json(ctx, param, arg): + if arg is None: + return + try: + query = json.loads(arg) + filter_groups = [FilterGroup.from_dict(group) for group in query["groups"]] + return filter_groups + except json.JSONDecodeError as json_error: + raise click.BadParameter("Unable to parse JSON: {}".format(json_error)) + except KeyError as key_error: + raise click.BadParameter( + "Unable to build query from input JSON: {}".format(key_error) + ) + + def create_search_options(search_term): begin_option = click.option( "-b", @@ -149,18 +154,22 @@ def create_search_options(search_term): ) advanced_query_option = click.option( "--advanced-query", - help="\b\nA raw JSON {} query. " - "Useful for when the provided query parameters do not satisfy your requirements." - "\nWARNING: Using advanced queries is incompatible with other query-building arguments.".format( + help="A raw JSON {} query. " + "Useful for when the provided query parameters do not satisfy your requirements. " + "Argument can be passed as a string, read from stdin by passing '-', or from a filename if " + "prefixed with '@', e.g. '--advanced-query @query.json'. " + "WARNING: Using advanced queries is incompatible with other query-building arguments.".format( search_term ), - callback=validate_advanced_query_is_json, + metavar="QUERY_JSON", + type=FileOrString(), + callback=_parse_query_from_json, ) checkpoint_option = click.option( "-c", "--use-checkpoint", - cls=AdvancedQueryAndSavedSearchIncompatible, help="Only get {} that were not previously retrieved.".format(search_term), + cls=incompatible_with("saved_search"), ) def search_options(f): diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index a68005a8b..8810bb9d9 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -12,14 +12,14 @@ import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt import code42cli.errors as errors +from code42cli.click_ext.groups import OrderedGroup +from code42cli.click_ext.options import incompatible_with from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.cmds.search.extraction import handle_no_events from code42cli.cmds.securitydata_output_formats import FileEventsOutputFormatter from code42cli.logger import get_logger_for_server from code42cli.logger import get_main_cli_logger from code42cli.options import format_option -from code42cli.options import incompatible_with -from code42cli.options import OrderedGroup from code42cli.options import sdk_options from code42cli.options import server_options from code42cli.output_formats import OutputFormatter @@ -206,12 +206,12 @@ def clear_checkpoint(state, checkpoint_name): def _call_extractor( state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs ): + if advanced_query: + state.search_filters = advanced_query extractor = _get_file_event_extractor(state.sdk, handlers) extractor.use_or_query = or_query extractor.or_query_exempt_filters.append(f.ExposureType.exists()) - if advanced_query: - extractor.extract_advanced(advanced_query) - elif saved_search: + if saved_search: extractor.extract(*saved_search._filter_group_list) else: if begin or end: diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index 515f67366..6ae1493c0 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -1,19 +1,9 @@ -import difflib -import re - import click from click._compat import get_text_stderr -from py42.exceptions import Py42ForbiddenError -from py42.exceptions import Py42HTTPError -from py42.exceptions import Py42InvalidRuleOperationError -from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError -from py42.exceptions import Py42UserAlreadyAddedError -from code42cli.logger import get_main_cli_logger from code42cli.logger import get_view_error_details_message ERRORED = False -_DIFFLIB_CUT_OFF = 0.6 class Code42CLIError(click.ClickException): @@ -71,82 +61,3 @@ def __init__(self, username, matter_id): username, matter_id ) ) - - -class ExceptionHandlingGroup(click.Group): - """Custom click.Group subclass to add custom exception handling.""" - - logger = get_main_cli_logger() - _original_args = None - - def make_context(self, info_name, args, parent=None, **extra): - - # grab the original command line arguments for logging purposes - self._original_args = " ".join(args) - - return super().make_context(info_name, args, parent=parent, **extra) - - def invoke(self, ctx): - try: - return super().invoke(ctx) - - except click.UsageError as err: - self._suggest_cmd(err) - - except LoggedCLIError: - raise - - except Code42CLIError as err: - self.logger.log_error(str(err)) - raise - - except click.ClickException: - raise - - except click.exceptions.Exit: - raise - - except ( - UserDoesNotExistError, - Py42UserAlreadyAddedError, - Py42InvalidRuleOperationError, - Py42LegalHoldNotFoundOrPermissionDeniedError, - ) as err: - self.logger.log_error(err) - raise Code42CLIError(str(err)) - - except Py42ForbiddenError as err: - self.logger.log_verbose_error(self._original_args, err.response.request) - raise LoggedCLIError( - "You do not have the necessary permissions to perform this task. " - "Try using or creating a different profile." - ) - - except Py42HTTPError as err: - self.logger.log_verbose_error(self._original_args, err.response.request) - raise LoggedCLIError("Problem making request to server.") - - except OSError: - raise - - except Exception: - self.logger.log_verbose_error() - raise LoggedCLIError("Unknown problem occurred.") - - @staticmethod - def _suggest_cmd(usage_err): - """Handles fuzzy suggestion of commands that are close to the bad command entered.""" - if usage_err.message is not None: - match = re.match("No such command '(.*)'.", usage_err.message) - if match: - bad_arg = match.groups()[0] - available_commands = list(usage_err.ctx.command.commands.keys()) - suggested_commands = difflib.get_close_matches( - bad_arg, available_commands, cutoff=_DIFFLIB_CUT_OFF - ) - if not suggested_commands: - raise usage_err - usage_err.message = "No such command '{}'. Did you mean {}?".format( - bad_arg, " or ".join(suggested_commands) - ) - raise usage_err diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 96e44f222..68a98907a 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -7,6 +7,7 @@ from code42cli import PRODUCT_NAME from code42cli.__version__ import __version__ as cliversion +from code42cli.click_ext.groups import ExceptionHandlingGroup from code42cli.cmds.alert_rules import alert_rules from code42cli.cmds.alerts import alerts from code42cli.cmds.departing_employee import departing_employee @@ -14,7 +15,6 @@ from code42cli.cmds.legal_hold import legal_hold from code42cli.cmds.profile import profile from code42cli.cmds.securitydata import security_data -from code42cli.errors import ExceptionHandlingGroup from code42cli.options import sdk_options BANNER = """\b diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 5a03c37f2..bdbc160ee 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - import click from code42cli.cmds.search.enums import ServerProtocol @@ -110,52 +108,6 @@ def decorator(f): return decorator -def incompatible_with(incompatible_opts): - - if isinstance(incompatible_opts, str): - incompatible_opts = [incompatible_opts] - - class IncompatibleOption(click.Option): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def handle_parse_result(self, ctx, opts, args): - # if None it means we're in autocomplete mode and don't want to validate - if ctx.obj is not None: - found_incompatible = ", ".join( - [ - "--{}".format(opt.replace("_", "-")) - for opt in opts - if opt in incompatible_opts - ] - ) - if self.name in opts and found_incompatible: - name = self.name.replace("_", "-") - raise click.BadOptionUsage( - option_name=self.name, - message="--{} can't be used with: {}".format( - name, found_incompatible - ), - ) - return super().handle_parse_result(ctx, opts, args) - - return IncompatibleOption - - -class OrderedGroup(click.Group): - """A click.Group subclass that uses OrderedDict to store commands so the help text lists them - in the order they were defined/added to the group. - """ - - def __init__(self, name=None, commands=None, **attrs): - super().__init__(name, commands, **attrs) - # the registered subcommands by their exported names. - self.commands = commands or OrderedDict() - - def list_commands(self, ctx): - return self.commands - - def server_options(f): hostname_arg = click.argument("hostname") protocol_option = click.option( diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 9aebe2e6f..e9d8139bd 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -106,6 +106,85 @@ {"id": 3, "createdAt": "2020-01-01"}, ] +ADVANCED_QUERY_VALUES = { + "state_1": "OPEN", + "state_2": "PENDING", + "state_3": "IN_PROGRESS", + "actor": "test@example.com", + "on_or_after": "2020-01-01T06:00:00.000Z", + "on_or_after_timestamp": 1577858400.0, + "on_or_before": "2020-02-01T06:00:00.000Z", + "on_or_before_timestamp": 1580536800.0, + "rule_id": "xyz123", +} +ADVANCED_QUERY_JSON = """ +{{ + "srtDirection": "DESC", + "pgNum": 0, + "pgSize": 100, + "srtKey": "CreatedAt", + "groups": [ + {{ + "filterClause": "OR", + "filters": [ + {{ + "value": "{state_1}", + "term": "state", + "operator": "IS" + }}, + {{ + "value": "{state_2}", + "term": "state", + "operator": "IS" + }}, + {{ + "value": "{state_3}", + "term": "state", + "operator": "IS" + }} + ] + }}, + {{ + "filterClause": "OR", + "filters": [ + {{ + "value": "{actor}", + "term": "actor", + "operator": "CONTAINS" + }} + ] + }}, + {{ + "filterClause": "AND", + "filters": [ + {{ + "value": "{on_or_after}", + "term": "createdAt", + "operator": "ON_OR_AFTER" + }}, + {{ + "value": "{on_or_before}", + "term": "createdAt", + "operator": "ON_OR_BEFORE" + }} + ] + }}, + {{ + "filterClause": "OR", + "filters": [ + {{ + "value": "{rule_id}", + "term": "ruleId", + "operator": "IS" + }} + ] + }} + ], + "groupClause": "AND" +}}""".format( + **ADVANCED_QUERY_VALUES +) + @pytest.fixture def alert_extractor(mocker): @@ -148,10 +227,7 @@ def alert_extract_func(mocker): return mocker.patch("{}.cmds.alerts._extract".format(PRODUCT_NAME)) -ADVANCED_QUERY_JSON = '{"some": "complex json"}' - - -def test_search_with_advanced_query_uses_only_the_extract_advanced_method( +def test_search_when_advanced_query_passed_as_json_string_builds_expected_query( cli_state, alert_extractor, runner ): runner.invoke( @@ -159,8 +235,26 @@ def test_search_with_advanced_query_uses_only_the_extract_advanced_method( ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state, ) - alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') - assert alert_extractor.extract.call_count == 0 + passed_filter_groups = alert_extractor.extract.call_args[0] + expected_actor_filter = f.Actor.contains(ADVANCED_QUERY_VALUES["actor"]) + expected_actor_filter.filter_clause = "OR" + expected_timestamp_filter = f.DateObserved.in_range( + ADVANCED_QUERY_VALUES["on_or_after_timestamp"], + ADVANCED_QUERY_VALUES["on_or_before_timestamp"], + ) + expected_state_filter = f.AlertState.is_in( + [ + ADVANCED_QUERY_VALUES["state_1"], + ADVANCED_QUERY_VALUES["state_2"], + ADVANCED_QUERY_VALUES["state_3"], + ] + ) + expected_rule_id_filter = f.RuleId.eq(ADVANCED_QUERY_VALUES["rule_id"]) + expected_rule_id_filter.filter_clause = "OR" + assert expected_actor_filter in passed_filter_groups + assert expected_timestamp_filter in passed_filter_groups + assert expected_state_filter in passed_filter_groups + assert expected_rule_id_filter in passed_filter_groups def test_search_without_advanced_query_uses_only_the_extract_method( @@ -190,7 +284,6 @@ def test_search_without_advanced_query_uses_only_the_extract_method( ("--exclude-rule-type", "FedEndpointExfiltration"), ("--description", "test"), ("--state", "OPEN"), - ("--use-checkpoint", "test"), ], ) def test_search_with_advanced_query_and_incompatible_argument_errors( @@ -639,19 +732,6 @@ def test_send_to_makes_call_to_the_extract_method( assert alert_extractor.extract_advanced.call_count == 0 -def test_send_to_makes_call_to_the_extract_advnced_method( - cli_state, alert_extractor, runner -): - - runner.invoke( - cli, - ["alerts", "send-to", "localhost", "--advanced-query", ADVANCED_QUERY_JSON], - obj=cli_state, - ) - assert alert_extractor.extract.call_count == 0 - alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') - - def test_send_to_when_given_description_uses_description_filter( cli_state, alert_extractor, runner ): @@ -666,27 +746,6 @@ def test_send_to_when_given_description_uses_description_filter( assert str(f.Description.contains(description)) in filter_strings -def test_send_to_with_advanced_query_uses_only_the_extract_advanced_method( - cli_state, alert_extractor, runner -): - runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--advanced-query", ADVANCED_QUERY_JSON], - obj=cli_state, - ) - alert_extractor.extract_advanced.assert_called_once_with('{"some": "complex json"}') - assert alert_extractor.extract.call_count == 0 - - -def test_send_to_without_advanced_query_uses_only_the_extract_method( - cli_state, alert_extractor, runner -): - - runner.invoke(cli, ["alerts", "send-to", "0.0.0.0", "--begin", "1d"], obj=cli_state) - assert alert_extractor.extract.call_count == 1 - assert alert_extractor.extract_advanced.call_count == 0 - - @pytest.mark.parametrize( "arg", [ @@ -705,7 +764,6 @@ def test_send_to_without_advanced_query_uses_only_the_extract_method( ("--exclude-rule-type", "FedEndpointExfiltration"), ("--description", "test"), ("--state", "OPEN"), - ("--use-checkpoint", "test"), ], ) def test_send_to_with_advanced_query_and_incompatible_argument_errors( diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 6459d6c25..79fd7fa0e 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -14,7 +14,6 @@ from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.main import cli - BEGIN_TIMESTAMP = 1577858400.0 END_TIMESTAMP = 1580450400.0 CURSOR_TIMESTAMP = 1579500000.0 @@ -32,6 +31,68 @@ TEST_EMPTY_LIST_RESPONSE = {"searches": []} +ADVANCED_QUERY_VALUES = { + "within_last_value": "P30D", + "hostname_1": "DESKTOP-H88BEKO", + "hostname_2": "W10E-X64-FALLCR", + "event_type": "CREATED", +} +ADVANCED_QUERY_JSON = """ +{{ + "purpose": "USER_EXECUTED_SEARCH", + "groups": [ + {{ + "filterClause": "AND", + "filters": [ + {{ + "value": "{within_last_value}", + "operator": "WITHIN_THE_LAST", + "term": "eventTimestamp" + }} + ] + }}, + {{ + "filterClause": "AND", + "filters": [ + {{ + "value": ".*", + "operator": "IS", + "term": "fileName" + }} + ] + }}, + {{ + "filterClause": "OR", + "filters": [ + {{ + "value": "{hostname_1}", + "operator": "IS", + "term": "osHostName" + }}, + {{ + "value": "{hostname_2}", + "operator": "IS", + "term": "osHostName" + }} + ] + }}, + {{ + "filterClause": "OR", + "filters": [ + {{ + "value": "{event_type}", + "operator": "IS", + "term": "eventType" + }} + ] + }} + ], + "pgSize": 100, + "pgNum": 1 +}}""".format( + **ADVANCED_QUERY_VALUES +) + @pytest.fixture def file_event_extractor(mocker): @@ -75,9 +136,6 @@ def begin_option(mocker): return mock -ADVANCED_QUERY_JSON = '{"some": "complex json"}' - - @pytest.mark.parametrize( "command", ( @@ -91,30 +149,71 @@ def begin_option(mocker): ], ), ) -def test_search_when_is_advanced_query_uses_only_the_extract_advanced_method( +def test_search_when_advanced_query_passed_as_json_string_builds_expected_query( runner, cli_state, file_event_extractor, command ): runner.invoke(cli, command, obj=cli_state) - file_event_extractor.extract_advanced.assert_called_once_with( - '{"some": "complex json"}' + passed_filter_groups = file_event_extractor.extract.call_args[0] + expected_event_filter = f.EventTimestamp.within_the_last( + ADVANCED_QUERY_VALUES["within_last_value"] + ) + expected_hostname_filter = f.OSHostname.is_in( + [ADVANCED_QUERY_VALUES["hostname_1"], ADVANCED_QUERY_VALUES["hostname_2"]] + ) + expected_event_type_filter = f.EventType.is_in( + [ADVANCED_QUERY_VALUES["event_type"]] ) - assert file_event_extractor.extract.call_count == 0 - assert file_event_extractor.extract_advanced.call_count == 1 + expected_event_type_filter.filter_clause = "OR" + assert expected_event_filter in passed_filter_groups + assert expected_hostname_filter in passed_filter_groups + assert expected_event_type_filter in passed_filter_groups @pytest.mark.parametrize( "command", ( - ["security-data", "search", "--begin", "1d"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1d"], + ["security-data", "search", "--advanced-query", "@query.json"], + ["security-data", "send-to", "0.0.0.0", "--advanced-query", "@query.json"], ), ) -def test_search_when_is_not_advanced_query_uses_only_the_extract_advanced_method( +def test_search_when_advanced_query_passed_as_filename_builds_expected_query( runner, cli_state, file_event_extractor, command ): - runner.invoke(cli, command, obj=cli_state) - assert file_event_extractor.extract_advanced.call_count == 0 - assert file_event_extractor.extract.call_count == 1 + with runner.isolated_filesystem(): + with open("query.json", "w") as jsonfile: + jsonfile.write(ADVANCED_QUERY_JSON) + + runner.invoke(cli, command, obj=cli_state) + passed_filter_groups = file_event_extractor.extract.call_args[0] + expected_event_filter = f.EventTimestamp.within_the_last( + ADVANCED_QUERY_VALUES["within_last_value"] + ) + expected_hostname_filter = f.OSHostname.is_in( + [ADVANCED_QUERY_VALUES["hostname_1"], ADVANCED_QUERY_VALUES["hostname_2"]] + ) + expected_event_type_filter = f.EventType.is_in( + [ADVANCED_QUERY_VALUES["event_type"]] + ) + expected_event_type_filter.filter_clause = "OR" + assert expected_event_filter in passed_filter_groups + assert expected_hostname_filter in passed_filter_groups + assert expected_event_type_filter in passed_filter_groups + + +@pytest.mark.parametrize( + "command", + ( + ["security-data", "search", "--advanced-query", "@not_a_file"], + ["security-data", "send-to", "0.0.0.0", "--advanced-query", "@not_a_file"], + ), +) +def test_search_when_advanced_query_passed_non_existent_filename_raises_error( + runner, cli_state, command +): + with runner.isolated_filesystem(): + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert "Could not open file: not_a_file" in result.stdout @pytest.mark.parametrize( @@ -134,7 +233,6 @@ def test_search_when_is_not_advanced_query_uses_only_the_extract_advanced_method ("--tab-url", "https://example.com"), ("--type", "SharedViaLink"), ("--include-non-exposure",), - ("--use-checkpoint", "test"), ], ) def test_search_with_advanced_query_and_incompatible_argument_errors( @@ -899,34 +997,3 @@ def test_saved_search_list_with_format_option_does_not_return_when_response_is_e cli, ["security-data", "saved-search", "list", "-f", "csv"], obj=cli_state ) assert "Name,Id" not in result.output - - -def test_send_to_when_is_advanced_query_uses_only_the_extract_advanced_method( - runner, cli_state, file_event_extractor, event_extractor_logger -): - runner.invoke( - cli, - [ - "security-data", - "send-to", - "localhost", - "--advanced-query", - ADVANCED_QUERY_JSON, - ], - obj=cli_state, - ) - file_event_extractor.extract_advanced.assert_called_once_with( - '{"some": "complex json"}' - ) - assert file_event_extractor.extract.call_count == 0 - assert file_event_extractor.extract_advanced.call_count == 1 - - -def test_send_to_when_is_not_advanced_query_uses_only_the_extract_advanced_method( - runner, cli_state, file_event_extractor -): - runner.invoke( - cli, ["security-data", "send-to", "localhost", "--begin", "1d"], obj=cli_state - ) - assert file_event_extractor.extract_advanced.call_count == 0 - assert file_event_extractor.extract.call_count == 1 From 3ee7344485e94f4acc077465e0fb7a7b2ee36c72 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 1 Oct 2020 11:41:11 -0500 Subject: [PATCH 136/349] add missing help text to --or-query option (#164) --- src/code42cli/cmds/securitydata.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 8810bb9d9..815fe64b4 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -302,7 +302,10 @@ def show(state, search_id): @file_event_options @search_options @click.option( - "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible + "--or-query", + is_flag=True, + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + help="Combine query filter options with 'OR' logic instead of the default 'AND'.", ) @sdk_options() @server_options From 137ec5a08bbec1c513b8102247ca11fdffa59bef Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Tue, 24 Nov 2020 19:11:44 +0530 Subject: [PATCH 137/349] added audit-logs command (#160) * added audit-logs command * remove format option * Added send-to command * added changelog * fix test * changed argument naming convention to singular * refactor search intervals * added format options * refactor * make begin date required * fix tests * added message when no results found and default header for table output * fix send-to when no results found * toggle py42 version * Fix integration test, removed output validation as made input dates dynamic * Add integration test for auditlogs * Fix style * added docs string and renamed variable * refactor: _send_to function * Rectify doc * Changed output fields for table format --- CHANGELOG.md | 6 + integration/test_alerts.py | 92 +++---------- integration/test_auditlogs.py | 13 ++ setup.py | 2 +- src/code42cli/cmds/auditlogs.py | 195 +++++++++++++++++++++++++++ src/code42cli/cmds/search/options.py | 37 ++--- src/code42cli/cmds/securitydata.py | 13 +- src/code42cli/main.py | 2 + src/code42cli/options.py | 39 ++++++ tests/cmds/test_auditlogs.py | 106 +++++++++++++++ 10 files changed, 400 insertions(+), 105 deletions(-) create mode 100644 integration/test_auditlogs.py create mode 100644 src/code42cli/cmds/auditlogs.py create mode 100644 tests/cmds/test_auditlogs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 945e296c7..4555d6dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Added + +- `code42 audit-logs` commands: + - `search` to search for audit-logs. + - `send-to` to send audit-logs to server. + ### Changed - The `--advanced-query` option on `alerts search` and `security-data (search|send-to)` commands has been updated: diff --git a/integration/test_alerts.py b/integration/test_alerts.py index b81c23a0d..c559b1abe 100644 --- a/integration/test_alerts.py +++ b/integration/test_alerts.py @@ -1,89 +1,31 @@ -import json +from datetime import datetime +from datetime import timedelta import pytest from integration import run_command -from integration.util import cleanup_after_validation -ALERT_COMMAND = "code42 alerts print -b 2020-05-18 -e 2020-05-20" +begin_date = datetime.utcnow() - timedelta(days=20) +end_date = datetime.utcnow() - timedelta(days=10) +begin_date_str = begin_date.strftime("%Y-%m-%d") +end_date_str = end_date.strftime("%Y-%m-%d") -def _parse_response(response): - return [json.loads(line) for line in response if len(line)] - - -def _validate_field_value(field, value, response): - parsed_response = _parse_response(response) - assert len(parsed_response) > 0 - for record in parsed_response: - assert record[field] == value +ALERT_COMMAND = "code42 alerts search -b {} -e {}".format(begin_date_str, end_date_str) @pytest.mark.parametrize( - "command, field, value", + "command", [ - ("{} --state OPEN".format(ALERT_COMMAND), "state", "OPEN"), - ("{} --state RESOLVED".format(ALERT_COMMAND), "state", "RESOLVED"), - ( - "{} --actor user@code42.com".format(ALERT_COMMAND), - "actor", - "user@code42.com", - ), - ( - "{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), - "name", - "File Upload Alert", - ), - ( - "{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND), - "ruleId", - "962a6a1c-54f6-4477-90bd-a08cc74cbf71", - ), - ( - "{} --rule-type FedEndpointExfiltration".format(ALERT_COMMAND), - "type", - "FED_ENDPOINT_EXFILTRATION", - ), - ( - "{} --description 'Alert on any file upload'".format(ALERT_COMMAND), - "description", - "Alert on any file upload events", - ), + ("{}".format(ALERT_COMMAND)), + ("{} --state OPEN".format(ALERT_COMMAND)), + ("{} --state RESOLVED".format(ALERT_COMMAND)), + ("{} --actor user@code42.com".format(ALERT_COMMAND)), + ("{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND)), + ("{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND)), + ("{} --rule-type FedEndpointExfiltration".format(ALERT_COMMAND)), + ("{} --description 'Alert on any file upload'".format(ALERT_COMMAND)), ], ) -def test_alert_prints_to_stdout_and_filters_result_by_given_value( - command, field, value -): +def test_alert_returns_success_return_code(command): return_code, response = run_command(command) assert return_code == 0 - _validate_field_value(field, value, response) - - -def _validate_begin_date(response): - parsed_response = _parse_response(response) - assert len(parsed_response) > 0 - for record in parsed_response: - assert record["createdAt"].startswith("2020-05-18") - - -@pytest.mark.parametrize("command, validate", [(ALERT_COMMAND, _validate_begin_date)]) -def test_alert_prints_to_stdout_and_filters_result_between_given_date( - command, validate -): - return_code, response = run_command(command) - assert return_code == 0 - validate(response) - - -def _validate_severity(response): - record = json.loads(response) - assert record["severity"] == "MEDIUM" - - -@cleanup_after_validation("./integration/alerts") -def test_alert_writes_to_file_and_filters_result_by_severity(): - command = ( - "code42 alerts write-to ./integration/alerts -b 2020-05-18 -e 2020-05-20 " - "--severity MEDIUM" - ) - return_code, response = run_command(command) - return _validate_severity diff --git a/integration/test_auditlogs.py b/integration/test_auditlogs.py new file mode 100644 index 000000000..d04137a6d --- /dev/null +++ b/integration/test_auditlogs.py @@ -0,0 +1,13 @@ +from datetime import datetime +from datetime import timedelta + +from integration import run_command + +BASE_COMMAND = "code42 auditlogs search -b" + + +def test_auditlogs_search(): + begin_date = datetime.utcnow() - timedelta(days=-10) + begin_date_str = begin_date.strftime("%Y-%m-%d %H:%M:%S") + return_code, response = run_command(BASE_COMMAND + begin_date_str) + assert return_code == 0 diff --git a/setup.py b/setup.py index b2c7a9fa5..afce8924b 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ "c42eventextractor==0.4.0", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42>=1.8.1", + "py42>=1.9", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py new file mode 100644 index 000000000..6e94db07f --- /dev/null +++ b/src/code42cli/cmds/auditlogs.py @@ -0,0 +1,195 @@ +import json +from _collections import OrderedDict + +import click + +from code42cli.click_ext.groups import OrderedGroup +from code42cli.date_helper import parse_max_timestamp +from code42cli.date_helper import parse_min_timestamp +from code42cli.logger import get_logger_for_server +from code42cli.options import begin_option +from code42cli.options import end_option +from code42cli.options import format_option +from code42cli.options import sdk_options +from code42cli.options import send_to_format_options +from code42cli.options import server_options +from code42cli.output_formats import OutputFormatter +from code42cli.util import warn_interrupt + +EVENT_KEY = "events" +AUDIT_LOGS_KEYWORD = "audit-logs" + +AUDIT_LOGS_DEFAULT_HEADER = OrderedDict() +AUDIT_LOGS_DEFAULT_HEADER["timestamp"] = "Timestamp" +AUDIT_LOGS_DEFAULT_HEADER["type$"] = "Type" +AUDIT_LOGS_DEFAULT_HEADER["actorName"] = "ActorName" +AUDIT_LOGS_DEFAULT_HEADER["actorIpAddress"] = "ActorIpAddress" +AUDIT_LOGS_DEFAULT_HEADER["userName"] = "AffectedUser" +AUDIT_LOGS_DEFAULT_HEADER["userId"] = "AffectedUserUID" +# AUDIT_LOGS_DEFAULT_HEADER["success"] = "Success" +# AUDIT_LOGS_DEFAULT_HEADER["resultCount"] = "ResultCount" + +filter_option_usernames = click.option( + "--username", required=False, help="Filter results by usernames.", multiple=True, +) +filter_option_user_ids = click.option( + "--user-id", required=False, help="Filter results by user ids.", multiple=True, +) + +filter_option_user_ip_addresses = click.option( + "--user-ip", + required=False, + help="Filter results by user ip addresses.", + multiple=True, +) +filter_option_affected_user_ids = click.option( + "--affected-user-id", + required=False, + help="Filter results by affected user ids.", + multiple=True, +) +filter_option_affected_usernames = click.option( + "--affected-username", + required=False, + help="Filter results by affected usernames.", + multiple=True, +) +filter_option_event_types = click.option( + "--event-type", + required=False, + help="Filter results by event types.", + multiple=True, +) + + +def filter_options(f): + f = begin_option( + f, + AUDIT_LOGS_KEYWORD, + callback=lambda ctx, param, arg: parse_min_timestamp(arg), + required=True, + ) + f = end_option( + f, AUDIT_LOGS_KEYWORD, callback=lambda ctx, param, arg: parse_max_timestamp(arg) + ) + f = filter_option_event_types(f) + f = filter_option_usernames(f) + f = filter_option_user_ids(f) + f = filter_option_user_ip_addresses(f) + f = filter_option_affected_user_ids(f) + f = filter_option_affected_usernames(f) + return f + + +@click.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def audit_logs(state): + """Retrieve audit logs.""" + pass + + +@audit_logs.command() +@filter_options +@format_option +@sdk_options() +def search( + state, + begin, + end, + event_type, + username, + user_id, + user_ip, + affected_user_id, + affected_username, + format, +): + """Search audit logs.""" + _search( + state.sdk, + format, + begin_time=begin, + end_time=end, + event_types=event_type, + usernames=username, + user_ids=user_id, + user_ip_addresses=user_ip, + affected_user_ids=affected_user_id, + affected_usernames=affected_username, + ) + + +@audit_logs.command() +@filter_options +@server_options +@send_to_format_options +@sdk_options() +def send_to( + state, + hostname, + protocol, + format, + begin, + end, + event_type, + username, + user_id, + user_ip, + affected_user_id, + affected_username, +): + """Send audit logs to the given server address.""" + _send_to( + state.sdk, + hostname, + protocol, + format, + begin_time=begin, + end_time=end, + event_types=event_type, + usernames=username, + user_ids=user_id, + user_ip_addresses=user_ip, + affected_user_ids=affected_user_id, + affected_usernames=affected_username, + ) + + +def _search(sdk, format, **filter_args): + + formatter = OutputFormatter(format, AUDIT_LOGS_DEFAULT_HEADER) + response_gen = sdk.auditlogs.get_all(**filter_args) + + events = [] + try: + for response in response_gen: + response_dict = json.loads(response.text) + if EVENT_KEY in response_dict: + events.extend(response_dict.get(EVENT_KEY)) + except KeyError: + # API endpoint (get_page) returns a response without events key when no records are found + # e.g {"paginationRangeStartIndex": 10000, "paginationRangeEndIndex": 10000, "totalResultCount": 1593} + pass + + event_count = len(events) + if not event_count: + click.echo("No results found.") + elif event_count > 10: + click.echo_via_pager(formatter.get_formatted_output(events)) + else: + formatter.echo_formatted_list(events) + + +def _send_to(sdk, hostname, protocol, format, **filter_args): + logger = get_logger_for_server(hostname, protocol, format) + with warn_interrupt(): + response_gen = sdk.auditlogs.get_all(**filter_args) + try: + for response in response_gen: + if EVENT_KEY in response: + for event in response[EVENT_KEY]: + logger.info(event) + else: + logger.info("No results found.") + except KeyError: + pass diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index a7fc74de2..e664f590b 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -10,6 +10,9 @@ from code42cli.date_helper import parse_max_timestamp from code42cli.date_helper import parse_min_timestamp from code42cli.logger import get_main_cli_logger +from code42cli.options import begin_option +from code42cli.options import end_option + logger = get_main_cli_logger() @@ -132,26 +135,24 @@ def _parse_query_from_json(ctx, param, arg): ) -def create_search_options(search_term): - begin_option = click.option( - "-b", - "--begin", - callback=lambda ctx, param, arg: parse_min_timestamp(arg), +def search_interval_options(f, search_term): + f = begin_option( + f, + search_term, cls=BeginOption, - help="The beginning of the date range in which to look for {}, can be a date/time in " - "yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' " - "portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') " - "or a short value representing days (30d), hours (24h) or minutes (15m) from current " - "time.".format(search_term), + callback=lambda ctx, param, arg: parse_min_timestamp(arg), ) - end_option = click.option( - "-e", - "--end", - callback=lambda ctx, param, arg: parse_max_timestamp(arg), + f = end_option( + f, + search_term, cls=AdvancedQueryAndSavedSearchIncompatible, - help="The end of the date range in which to look for {}, argument format options are " - "the same as `--begin`.".format(search_term), + callback=lambda ctx, param, arg: parse_max_timestamp(arg), ) + return f + + +def create_search_options(search_term): + advanced_query_option = click.option( "--advanced-query", help="A raw JSON {} query. " @@ -173,8 +174,8 @@ def create_search_options(search_term): ) def search_options(f): - f = begin_option(f) - f = end_option(f) + + f = search_interval_options(f, search_term) f = checkpoint_option(f) f = advanced_query_option(f) return f diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 815fe64b4..705105e81 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -21,10 +21,9 @@ from code42cli.logger import get_main_cli_logger from code42cli.options import format_option from code42cli.options import sdk_options +from code42cli.options import send_to_format_options from code42cli.options import server_options from code42cli.output_formats import OutputFormatter -from code42cli.output_formats import SendToFileEventsOutputFormat - logger = get_main_cli_logger() @@ -161,15 +160,6 @@ def _get_saved_search_query(ctx, param, arg): ) -send_to_format_options = click.option( - "-f", - "--format", - type=click.Choice(SendToFileEventsOutputFormat(), case_sensitive=False), - help="The output format of the result. Defaults to json format.", - default=SendToFileEventsOutputFormat.RAW, -) - - def file_event_options(f): f = exposure_type_option(f) f = username_option(f) @@ -274,6 +264,7 @@ def search( @security_data.group(cls=OrderedGroup) @sdk_options() def saved_search(state): + """Search for file events using saved searches.""" pass diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 68a98907a..02dc20e68 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -10,6 +10,7 @@ from code42cli.click_ext.groups import ExceptionHandlingGroup from code42cli.cmds.alert_rules import alert_rules from code42cli.cmds.alerts import alerts +from code42cli.cmds.auditlogs import audit_logs from code42cli.cmds.departing_employee import departing_employee from code42cli.cmds.high_risk_employee import high_risk_employee from code42cli.cmds.legal_hold import legal_hold @@ -60,3 +61,4 @@ def cli(state): cli.add_command(high_risk_employee) cli.add_command(legal_hold) cli.add_command(profile) +cli.add_command(audit_logs) diff --git a/src/code42cli/options.py b/src/code42cli/options.py index bdbc160ee..cfe8599af 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -3,9 +3,22 @@ from code42cli.cmds.search.enums import ServerProtocol from code42cli.errors import Code42CLIError from code42cli.output_formats import OutputFormat +from code42cli.output_formats import SendToFileEventsOutputFormat from code42cli.profile import get_profile from code42cli.sdk_client import create_sdk +BEGIN_OPTION_HELP_MESSAGE = ( + "The beginning of the date range in which to look for {}, can be a date/time in " + "yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' " + "portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') " + "or a short value representing days (30d), hours (24h) or minutes (15m) from current " + "time." +) + +END_OPTION_HELP_MESSAGE = ( + "The end of the date range in which to look for {}, argument format options are " + "the same as `--begin`." +) yes_option = click.option( "-y", @@ -25,6 +38,23 @@ ) +def begin_option(f, search_term, **kwargs): + start_time = click.option( + "-b", "--begin", help=BEGIN_OPTION_HELP_MESSAGE.format(search_term), **kwargs + ) + + f = start_time(f) + return f + + +def end_option(f, search_term, **kwargs): + end_time = click.option( + "-e", "--end", help=END_OPTION_HELP_MESSAGE.format(search_term), **kwargs + ) + f = end_time(f) + return f + + class CLIState: def __init__(self): try: @@ -120,3 +150,12 @@ def server_options(f): f = hostname_arg(f) f = protocol_option(f) return f + + +send_to_format_options = click.option( + "-f", + "--format", + type=click.Choice(SendToFileEventsOutputFormat(), case_sensitive=False), + help="The output format of the result. Defaults to json format.", + default=SendToFileEventsOutputFormat.RAW, +) diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py new file mode 100644 index 000000000..b851323ec --- /dev/null +++ b/tests/cmds/test_auditlogs.py @@ -0,0 +1,106 @@ +from datetime import datetime +from datetime import timedelta + +import pytest +from py42.response import Py42Response +from requests import Response + +from code42cli.date_helper import parse_max_timestamp +from code42cli.date_helper import parse_min_timestamp +from code42cli.main import cli + + +@pytest.fixture +def date_str(): + dt = datetime.utcnow() - timedelta(days=10) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def test_search_audit_logs_json_format(runner, cli_state, date_str): + runner.invoke(cli, ["audit-logs", "search", "-b", date_str], obj=cli_state) + assert cli_state.sdk.auditlogs.get_all.call_count == 1 + + +def test_search_audit_logs_with_filter_parameters(runner, cli_state, date_str): + runner.invoke( + cli, + [ + "audit-logs", + "search", + "--username", + "test@test.com", + "--username", + "test2@test.test", + "--begin", + date_str, + ], + obj=cli_state, + ) + assert cli_state.sdk.auditlogs.get_all.call_count == 1 + cli_state.sdk.auditlogs.get_all.assert_called_once_with( + usernames=("test@test.com", "test2@test.test"), + affected_user_ids=(), + affected_usernames=(), + begin_time=parse_min_timestamp(date_str), + end_time=None, + event_types=(), + user_ids=(), + user_ip_addresses=(), + ) + + +def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_str): + end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + runner.invoke( + cli, + [ + "audit-logs", + "search", + "--username", + "test@test.com", + "--username", + "test2@test.test", + "--event-type", + "saved-search", + "--user-ip", + "0.0.0.0", + "--affected-username", + "test@test.test", + "--affected-user-id", + "123", + "--affected-user-id", + "456", + "--user-id", + "userid", + "-b", + date_str, + "--end", + end_time, + ], + obj=cli_state, + ) + assert cli_state.sdk.auditlogs.get_all.call_count == 1 + cli_state.sdk.auditlogs.get_all.assert_called_once_with( + usernames=("test@test.com", "test2@test.test"), + affected_user_ids=("123", "456"), + affected_usernames=("test@test.test",), + begin_time=parse_min_timestamp(date_str), + end_time=parse_max_timestamp(end_time), + event_types=("saved-search",), + user_ids=("userid",), + user_ip_addresses=("0.0.0.0",), + ) + + +def test_send_to_makes_call_to_the_extract_method( + cli_state, runner, event_extractor_logger, mocker +): + + http_response = mocker.MagicMock(spec=Response) + http_response.text = '{"events": [{"property": "bar"}]}' + py42_response = Py42Response(http_response) + cli_state.sdk.auditlogs.get_all.return_value = [py42_response] + runner.invoke( + cli, ["audit-logs", "send-to", "localhost", "--begin", "1d"], obj=cli_state + ) + assert cli_state.sdk.auditlogs.get_all.call_count == 1 From 28aa2b531238e2d7aea66e972a0bda9a0981c72d Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Mon, 7 Dec 2020 19:53:14 +0530 Subject: [PATCH 138/349] Fetch exit status from process module instead of sending hard-coded values (#170) --- integration/__init__.py | 6 ++++-- integration/test_auditlogs.py | 12 +++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/integration/__init__.py b/integration/__init__.py index 469c2f3e8..6b5f4664b 100644 --- a/integration/__init__.py +++ b/integration/__init__.py @@ -27,8 +27,10 @@ def run_command(command): output = process.before response = encode_response(output).splitlines() except pexpect.TIMEOUT: - return 1, response - return 0, response + process.close() + return process.exitstatus, response + process.close() + return process.exitstatus, response __all__ = [run_command] diff --git a/integration/test_auditlogs.py b/integration/test_auditlogs.py index d04137a6d..047f4c219 100644 --- a/integration/test_auditlogs.py +++ b/integration/test_auditlogs.py @@ -1,13 +1,15 @@ from datetime import datetime from datetime import timedelta +import pytest from integration import run_command -BASE_COMMAND = "code42 auditlogs search -b" +BASE_COMMAND = "code42 audit-logs search -b" +begin_date = datetime.utcnow() - timedelta(days=-10) +begin_date_str = begin_date.strftime("%Y-%m-%d %H:%M:%S") -def test_auditlogs_search(): - begin_date = datetime.utcnow() - timedelta(days=-10) - begin_date_str = begin_date.strftime("%Y-%m-%d %H:%M:%S") - return_code, response = run_command(BASE_COMMAND + begin_date_str) +@pytest.mark.parametrize("command", [("{} '{}'".format(BASE_COMMAND, begin_date_str))]) +def test_auditlogs_search(command): + return_code, response = run_command(command) assert return_code == 0 From e7b2d214bf1e539277677f508ff81f7abacf631f Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 8 Dec 2020 15:46:24 -0600 Subject: [PATCH 139/349] fix and improve (#172) --- tox.ini | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 444692010..6031d7c65 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,37,36,35,27} + py{38,37,36,35} docs style skip_missing_interpreters = true @@ -25,8 +25,11 @@ deps = recommonmark sphinx_rtd_theme sphinx-click +whitelist_externals = bash -commands = sphinx-build -W -b html -d {envtmpdir}/doctress docs {envtmpdir}/html +commands = + sphinx-build -W -b html -d "{envtmpdir}/doctrees" docs "{envtmpdir}/html" + bash -c "open {envtmpdir}/html/index.html || true" [testenv:style] deps = pre-commit From 52eb67ab7b685323bb81839ada92466b9db2cb4b Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 11 Dec 2020 15:04:44 -0600 Subject: [PATCH 140/349] Bugfix: make profile name required for delete (#175) --- CHANGELOG.md | 7 +++++++ src/code42cli/cmds/profile.py | 13 ++++++++----- src/code42cli/profile.py | 1 + tests/cmds/test_profile.py | 6 ++++++ tests/test_profile.py | 18 ++++++++++++++++++ 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4555d6dea..6bd7c4c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Fixed + +- Issue where `code42 profile delete` was allowed without giving a `profile_name` even + though deleting the default profile is not allowed. + ### Added - `code42 audit-logs` commands: @@ -18,6 +23,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Changed +- `profile_name` argument is now required for `code42 profile delete`, as it was meant to be. + - The `--advanced-query` option on `alerts search` and `security-data (search|send-to)` commands has been updated: - It can now accept the query as a JSON string or as the path to a file containing the JSON query. - It can be used with the `--use-checkpoint/-c` option. diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 4d2f2de51..5d067050d 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -18,7 +18,10 @@ def profile(): pass -profile_name_arg = click.argument("profile_name", required=False) +def profile_name_arg(required=False): + return click.argument("profile_name", required=required) + + name_option = click.option( "-n", "--name", @@ -48,7 +51,7 @@ def profile(): @profile.command() -@profile_name_arg +@profile_name_arg() def show(profile_name): """Print the details of a profile.""" c42profile = cliprofile.get_profile(profile_name) @@ -96,7 +99,7 @@ def update(name, server, username, password, disable_ssl_errors): @profile.command() -@profile_name_arg +@profile_name_arg() def reset_pw(profile_name): """\b Change the stored password for a profile. Only affects what's stored in the local profile, @@ -117,7 +120,7 @@ def _list(): @profile.command() -@profile_name_arg +@profile_name_arg() def use(profile_name): """Set a profile as the default.""" cliprofile.switch_default_profile(profile_name) @@ -126,7 +129,7 @@ def use(profile_name): @profile.command() @yes_option -@profile_name_arg +@profile_name_arg(required=True) def delete(profile_name): """Deletes a profile and its stored password (if any).""" message = "\nDeleting this profile will also delete any stored passwords and checkpoints. Are you sure? (y/n): " diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index 22b2e6083..385492dfe 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -108,6 +108,7 @@ def create_profile(name, server, username, ignore_ssl_errors): def delete_profile(profile_name): profile = _get_profile(profile_name) + profile_name = profile.name if password.get_stored_password(profile) is not None: password.delete_password(profile) cursor_stores = get_all_cursor_stores_for_profile(profile_name) diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index cb55a7edd..30733cc5b 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -333,6 +333,12 @@ def test_delete_profile_warns_if_deleting_default(runner, mock_cliprofile_namesp assert "'mockdefault' is currently the default profile!" in result.output +def test_delete_profile_requires_profile_name_arg(runner, mock_cliprofile_namespace): + result = runner.invoke(cli, ["profile", "delete"]) + assert "Error: Missing argument 'PROFILE_NAME'." in result.output + assert mock_cliprofile_namespace.delete_profile.call_count == 0 + + def test_delete_profile_does_nothing_if_user_doesnt_agree( runner, user_disagreement, mock_cliprofile_namespace ): diff --git a/tests/test_profile.py b/tests/test_profile.py index 023cd4160..aafb7a7c2 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -201,6 +201,24 @@ def test_set_password_uses_expected_password(config_accessor, password_setter): assert password_setter.call_args[0][1] == "newpassword" +def test_delete_profile_deletes_profile(config_accessor, mocker): + name = "deleteme" + profile = create_mock_profile(name) + mock_get_profile = mocker.patch("code42cli.profile._get_profile") + mock_get_profile.return_value = profile + cliprofile.delete_profile(name) + config_accessor.delete_profile.assert_called_once_with(name) + + +def test_delete_profile_deletes_profile_from_object_name(config_accessor, mocker): + expected = "deleteme - different name than the arg" + profile = create_mock_profile(expected) + mock_get_profile = mocker.patch("code42cli.profile._get_profile") + mock_get_profile.return_value = profile + cliprofile.delete_profile("deleteme") + config_accessor.delete_profile.assert_called_once_with(expected) + + def test_delete_profile_deletes_password_if_exists( config_accessor, mocker, password_getter, password_deleter ): From 9d7721760fe64f2fd344ebee1bd457efa2fdf154 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 14 Dec 2020 12:09:01 -0600 Subject: [PATCH 141/349] Migrate set-env (#177) --- .github/workflows/publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6cda429ba..df7361937 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,12 +24,12 @@ jobs: run: | src_file=( ./dist/*.tar.gz ) wheel_file=( ./dist/*.whl ) - echo "::set-env name=RELEASE_ID::$(jq --raw-output '.release.id' $GITHUB_EVENT_PATH)" - echo "::set-env name=SOURCE_DIST_FILE::$(basename $src_file)" - echo "::set-env name=WHEEL_FILE::$(basename $wheel_file)" + echo "RELEASE_ID=$(jq --raw-output '.release.id' $GITHUB_EVENT_PATH)" >> $GITHUB_ENV + echo "SOURCE_DIST_FILE=$(jq --raw-output '.release.id' $GITHUB_EVENT_PATH)" >> $GITHUB_ENV + echo "WHEEL_FILE=$(basename $wheel_file)" >> $GITHUB_ENV - name: Set Upload Url run: | - echo "::set-env name=UPLOAD_URL::https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets{?name,label}" + echo "UPLOAD_URL=https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets{?name,label}" >> $GITHUB_ENV - name: Output Variables For Uploading id: get_upload_vars run: | From 73a620f6d8c4d08a4da9b35ec856bca33e9c79df Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 15 Dec 2020 07:41:47 -0600 Subject: [PATCH 142/349] Bugfix/audit-logs checkpoints (#169) * added audit-logs command * remove format option * Added send-to command * added changelog * fix test * changed argument naming convention to singular * refactor search intervals * added format options * refactor * make begin date required * fix tests * added message when no results found and default header for table output * fix send-to when no results found * toggle py42 version * Fix integration test, removed output validation as made input dates dynamic * Add integration test for auditlogs * Fix style * added docs string and renamed variable * refactor: _send_to function * Rectify doc * Changed output fields for table format * add checkpointing to audit-logs commands * sort audit-log events * fix style failures * fix tests * fix moar tests * re-implement checkpointing with deduping by event hashes (which are now also stored in cursor) * use parent class methods since they're the same * update tests, fix style * fix missing mock logger * add docstring to dedupe function * style * add auditlog cursor to profile delete test * make response fixtures generators to match actual get_all behavior * clarify use_checkpoint value is checkpoint name not a boolean * add TestAuditLogCursorStore class * no need to return when writing * fix clear_checkpoint helptext * style * fix "timestamp w/ missing ms" bug and add test * style Co-authored-by: Kiran Chaudhary --- src/code42cli/cmds/auditlogs.py | 171 ++++++++--- src/code42cli/cmds/search/cursor_store.py | 31 +- src/code42cli/util.py | 8 + tests/cmds/search/test_cursor_store.py | 171 ++++++++++- tests/cmds/test_auditlogs.py | 330 +++++++++++++++++++++- tests/test_profile.py | 5 +- 6 files changed, 653 insertions(+), 63 deletions(-) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index 6e94db07f..b5ad6961d 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -1,9 +1,12 @@ -import json -from _collections import OrderedDict +from collections import OrderedDict +from datetime import datetime +from datetime import timezone import click from code42cli.click_ext.groups import OrderedGroup +from code42cli.cmds.search.cursor_store import AuditLogCursorStore +from code42cli.cmds.search.options import BeginOption from code42cli.date_helper import parse_max_timestamp from code42cli.date_helper import parse_min_timestamp from code42cli.logger import get_logger_for_server @@ -14,10 +17,12 @@ from code42cli.options import send_to_format_options from code42cli.options import server_options from code42cli.output_formats import OutputFormatter +from code42cli.util import hash_event from code42cli.util import warn_interrupt EVENT_KEY = "events" AUDIT_LOGS_KEYWORD = "audit-logs" +AUDIT_LOG_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" AUDIT_LOGS_DEFAULT_HEADER = OrderedDict() AUDIT_LOGS_DEFAULT_HEADER["timestamp"] = "Timestamp" @@ -26,8 +31,7 @@ AUDIT_LOGS_DEFAULT_HEADER["actorIpAddress"] = "ActorIpAddress" AUDIT_LOGS_DEFAULT_HEADER["userName"] = "AffectedUser" AUDIT_LOGS_DEFAULT_HEADER["userId"] = "AffectedUserUID" -# AUDIT_LOGS_DEFAULT_HEADER["success"] = "Success" -# AUDIT_LOGS_DEFAULT_HEADER["resultCount"] = "ResultCount" + filter_option_usernames = click.option( "--username", required=False, help="Filter results by usernames.", multiple=True, @@ -67,7 +71,7 @@ def filter_options(f): f, AUDIT_LOGS_KEYWORD, callback=lambda ctx, param, arg: parse_min_timestamp(arg), - required=True, + cls=BeginOption, ) f = end_option( f, AUDIT_LOGS_KEYWORD, callback=lambda ctx, param, arg: parse_max_timestamp(arg) @@ -81,16 +85,34 @@ def filter_options(f): return f +checkpoint_option = click.option( + "-c", + "--use-checkpoint", + metavar="checkpoint", + help="Only get audit-log events that were not previously retrieved.", +) + + @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def audit_logs(state): """Retrieve audit logs.""" - pass + # store cursor getter on the group state so shared --begin option can use it in validation + state.cursor_getter = _get_audit_log_cursor_store + + +@audit_logs.command() +@click.argument("checkpoint-name") +@sdk_options() +def clear_checkpoint(state, checkpoint_name): + """Remove the saved audit log checkpoint from `--use-checkpoint/-c` mode.""" + _get_audit_log_cursor_store(state.profile.name).delete(checkpoint_name) @audit_logs.command() @filter_options @format_option +@checkpoint_option @sdk_options() def search( state, @@ -103,11 +125,19 @@ def search( affected_user_id, affected_username, format, + use_checkpoint, ): """Search audit logs.""" - _search( + formatter = OutputFormatter(format, AUDIT_LOGS_DEFAULT_HEADER) + cursor = _get_audit_log_cursor_store(state.profile.name) + if use_checkpoint: + checkpoint_name = use_checkpoint + checkpoint = cursor.get(checkpoint_name) + if checkpoint is not None: + begin = checkpoint + + events = _get_all_audit_log_events( state.sdk, - format, begin_time=begin, end_time=end, event_types=event_type, @@ -117,10 +147,25 @@ def search( affected_user_ids=affected_user_id, affected_usernames=affected_username, ) + if use_checkpoint: + checkpoint_name = use_checkpoint + events = list( + _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, events + ) + ) + if not events: + click.echo("No results found.") + return + elif len(events) > 10: + click.echo_via_pager(formatter.get_formatted_output(events)) + else: + formatter.echo_formatted_list(events) @audit_logs.command() @filter_options +@checkpoint_option @server_options @send_to_format_options @sdk_options() @@ -137,13 +182,19 @@ def send_to( user_ip, affected_user_id, affected_username, + use_checkpoint, ): """Send audit logs to the given server address.""" - _send_to( + logger = get_logger_for_server(hostname, protocol, format) + cursor = _get_audit_log_cursor_store(state.profile.name) + if use_checkpoint: + checkpoint_name = use_checkpoint + checkpoint = cursor.get(checkpoint_name) + if checkpoint is not None: + begin = checkpoint + + events = _get_all_audit_log_events( state.sdk, - hostname, - protocol, - format, begin_time=begin, end_time=end, event_types=event_type, @@ -153,43 +204,81 @@ def send_to( affected_user_ids=affected_user_id, affected_usernames=affected_username, ) + if use_checkpoint: + checkpoint_name = use_checkpoint + events = list( + _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, events + ) + ) + with warn_interrupt(): + event = None + for event in events: + logger.info(event) + if event is None: # generator was empty + click.echo("No results found.") -def _search(sdk, format, **filter_args): - - formatter = OutputFormatter(format, AUDIT_LOGS_DEFAULT_HEADER) +def _get_all_audit_log_events(sdk, **filter_args): response_gen = sdk.auditlogs.get_all(**filter_args) - events = [] try: - for response in response_gen: - response_dict = json.loads(response.text) - if EVENT_KEY in response_dict: - events.extend(response_dict.get(EVENT_KEY)) + responses = list(response_gen) except KeyError: # API endpoint (get_page) returns a response without events key when no records are found # e.g {"paginationRangeStartIndex": 10000, "paginationRangeEndIndex": 10000, "totalResultCount": 1593} - pass + # we can remove this check once PL-93211 is resolved and deployed. + return events - event_count = len(events) - if not event_count: - click.echo("No results found.") - elif event_count > 10: - click.echo_via_pager(formatter.get_formatted_output(events)) - else: - formatter.echo_formatted_list(events) + for response in responses: + if EVENT_KEY in response.data: + response_events = response.data.get(EVENT_KEY) + events.extend(response_events) + return sorted(events, key=lambda x: x.get("timestamp")) -def _send_to(sdk, hostname, protocol, format, **filter_args): - logger = get_logger_for_server(hostname, protocol, format) - with warn_interrupt(): - response_gen = sdk.auditlogs.get_all(**filter_args) - try: - for response in response_gen: - if EVENT_KEY in response: - for event in response[EVENT_KEY]: - logger.info(event) - else: - logger.info("No results found.") - except KeyError: - pass + +def _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, events +): + """De-duplicates events across checkpointed runs. Since using the timestamp of the last event + processed as the `--begin` time of the next run causes the last event to show up again in the + next results, we hash the last event(s) of each run and store those hashes in the cursor to + filter out on the next run. It's also possible that two events have the exact same timestamp, so + `checkpoint_events` needs to be a list of hashes so we can filter out everything that's actually + been processed. + """ + + checkpoint_events = cursor.get_events(checkpoint_name) + new_timestamp = None + new_events = [] + for event in events: + event_hash = hash_event(event) + if event_hash not in checkpoint_events: + if event["timestamp"] != new_timestamp: + new_timestamp = event["timestamp"] + new_events.clear() + new_events.append(event_hash) + yield event + ts = _parse_audit_log_timestamp_string_to_timestamp(new_timestamp) + cursor.replace(checkpoint_name, ts) + cursor.replace_events(checkpoint_name, new_events) + + +def _get_audit_log_cursor_store(profile_name): + return AuditLogCursorStore(profile_name) + + +def _parse_audit_log_timestamp_string_to_timestamp(ts): + # example: {"property": "bar", "timestamp": "2020-11-23T17:13:26.239647Z"} + ts = ts[:-1] + try: + dt = datetime.strptime(ts, AUDIT_LOG_TIMESTAMP_FORMAT).replace( + tzinfo=timezone.utc + ) + except ValueError: + ts = ts + ".0" # handle timestamps that are missing ms + dt = datetime.strptime(ts, AUDIT_LOG_TIMESTAMP_FORMAT).replace( + tzinfo=timezone.utc + ) + return dt.timestamp() diff --git a/src/code42cli/cmds/search/cursor_store.py b/src/code42cli/cmds/search/cursor_store.py index 2dd74586d..ae13e3841 100644 --- a/src/code42cli/cmds/search/cursor_store.py +++ b/src/code42cli/cmds/search/cursor_store.py @@ -1,3 +1,4 @@ +import json import os from os import path @@ -37,7 +38,7 @@ def replace(self, cursor_name, new_timestamp): """Replaces the last stored date observed timestamp with the given one.""" location = path.join(self._dir_path, cursor_name) with open(location, "w") as checkpoint: - return checkpoint.write(str(new_timestamp)) + checkpoint.write(str(new_timestamp)) def delete(self, cursor_name): """Removes a single cursor from the store.""" @@ -75,13 +76,31 @@ def __init__(self, profile_name): super().__init__(dir_path) -def get_file_event_cursor_store(profile_name): - return FileEventCursorStore(profile_name) +class AuditLogCursorStore(BaseCursorStore): + def __init__(self, profile_name): + dir_path = get_user_project_path("audit_log_checkpoints", profile_name) + super().__init__(dir_path) + def get_events(self, cursor_name): + try: + location = path.join(self._dir_path, cursor_name) + "_events" + with open(location) as checkpoint: + try: + return json.loads(checkpoint.read()) + except json.JSONDecodeError: + return [] + except FileNotFoundError: + return [] -def get_alert_cursor_store(profile_name): - return AlertCursorStore(profile_name) + def replace_events(self, cursor_name, new_events): + location = path.join(self._dir_path, cursor_name) + "_events" + with open(location, "w") as checkpoint: + checkpoint.write(json.dumps(new_events)) def get_all_cursor_stores_for_profile(profile_name): - return [FileEventCursorStore(profile_name), AlertCursorStore(profile_name)] + return [ + FileEventCursorStore(profile_name), + AlertCursorStore(profile_name), + AuditLogCursorStore(profile_name), + ] diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 33466f7c0..7fb1919f3 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,7 +1,9 @@ +import json import os import shutil from collections import OrderedDict from functools import wraps +from hashlib import md5 from os import path from signal import getsignal from signal import SIGINT @@ -167,3 +169,9 @@ def _get_default_header(header_items): if key not in header and isinstance(key, str): header[key] = key return header + + +def hash_event(event): + if isinstance(event, dict): + event = json.dumps(event, sort_keys=True) + return md5(event.encode()).hexdigest() diff --git a/tests/cmds/search/test_cursor_store.py b/tests/cmds/search/test_cursor_store.py index e7de86f91..ec63278b1 100644 --- a/tests/cmds/search/test_cursor_store.py +++ b/tests/cmds/search/test_cursor_store.py @@ -4,6 +4,7 @@ from code42cli import PRODUCT_NAME from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.cmds.search.cursor_store import AuditLogCursorStore from code42cli.cmds.search.cursor_store import Cursor from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.errors import Code42CLIError @@ -13,6 +14,10 @@ _NAMESPACE = "{}.cmds.search.cursor_store".format(PRODUCT_NAME) +ALERT_CHECKPOINT_FOLDER_NAME = "alert_checkpoints" +FILE_EVENT_CHECKPOINT_FOLDER_NAME = "file_event_checkpoints" +AUDIT_LOG_CHECKPOINT_FOLDER_NAME = "audit_log_checkpoints" + @pytest.fixture def mock_open(mocker): @@ -20,6 +25,23 @@ def mock_open(mocker): return mock +AUDIT_LOG_EVENT_HASH_1 = "bc8f70ff821cadcc3e717d534d14737d" +AUDIT_LOG_EVENT_HASH_2 = "66ad12c0a0dba2b41520fb69aeefd84d" + + +@pytest.fixture +def mock_open_events(mocker): + mock = mocker.patch( + "builtins.open", + mocker.mock_open( + read_data='["{}", "{}"]'.format( + AUDIT_LOG_EVENT_HASH_1, AUDIT_LOG_EVENT_HASH_2 + ) + ), + ) + return mock + + @pytest.fixture def mock_isfile(mocker): mock = mocker.patch("{}.os.path.isfile".format(_NAMESPACE)) @@ -60,7 +82,7 @@ def test_get_reads_expected_file(self, mock_open): store.get(CURSOR_NAME) user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join( - user_path, "alert_checkpoints", PROFILE_NAME, CURSOR_NAME + user_path, ALERT_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, CURSOR_NAME ) mock_open.assert_called_once_with(expected_path) @@ -69,7 +91,7 @@ def test_replace_writes_to_expected_file(self, mock_open): store.replace("checkpointname", 123) user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join( - user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname" + user_path, ALERT_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "checkpointname" ) mock_open.assert_called_once_with(expected_path, "w") @@ -77,7 +99,9 @@ def test_replace_writes_expected_content(self, mock_open): store = AlertCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) user_path = path.join(path.expanduser("~"), ".code42cli") - path.join(user_path, "alert_checkpoints", PROFILE_NAME, "checkpointname") + path.join( + user_path, ALERT_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "checkpointname" + ) mock_open.return_value.write.assert_called_once_with("123") def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): @@ -85,7 +109,7 @@ def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): store.delete("deleteme") user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join( - user_path, "alert_checkpoints", PROFILE_NAME, "deleteme" + user_path, ALERT_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "deleteme" ) mock_remove.assert_called_once_with(expected_path) @@ -128,7 +152,7 @@ def test_get_reads_expected_file(self, mock_open): store.get(CURSOR_NAME) user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join( - user_path, "file_event_checkpoints", PROFILE_NAME, CURSOR_NAME + user_path, FILE_EVENT_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, CURSOR_NAME ) mock_open.assert_called_once_with(expected_path) @@ -144,7 +168,7 @@ def test_replace_writes_to_expected_file(self, mock_open): store.replace("checkpointname", 123) user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join( - user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname" + user_path, FILE_EVENT_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "checkpointname" ) mock_open.assert_called_once_with(expected_path, "w") @@ -152,7 +176,9 @@ def test_replace_writes_expected_content(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) store.replace("checkpointname", 123) user_path = path.join(path.expanduser("~"), ".code42cli") - path.join(user_path, "file_event_checkpoints", PROFILE_NAME, "checkpointname") + path.join( + user_path, FILE_EVENT_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "checkpointname" + ) mock_open.return_value.write.assert_called_once_with("123") def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): @@ -160,7 +186,7 @@ def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): store.delete("deleteme") user_path = path.join(path.expanduser("~"), ".code42cli") expected_path = path.join( - user_path, "file_event_checkpoints", PROFILE_NAME, "deleteme" + user_path, FILE_EVENT_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "deleteme" ) mock_remove.assert_called_once_with(expected_path) @@ -188,3 +214,132 @@ def test_get_all_cursors_returns_all_checkpoints(self, mock_listdir, mock_isfile assert cursors[0].name == "fileone" assert cursors[1].name == "filetwo" assert cursors[2].name == "filethree" + + +class TestAuditLogCursorStore: + def test_get_returns_expected_timestamp(self, mock_open): + store = AuditLogCursorStore(PROFILE_NAME) + checkpoint = store.get(CURSOR_NAME) + assert checkpoint == 123456789 + + def test_get_reads_expected_file(self, mock_open): + store = AuditLogCursorStore(PROFILE_NAME) + store.get(CURSOR_NAME) + user_path = path.join(path.expanduser("~"), ".code42cli") + expected_path = path.join( + user_path, AUDIT_LOG_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, CURSOR_NAME + ) + mock_open.assert_called_once_with(expected_path) + + def test_get_when_profile_does_not_exist_returns_none(self, mocker): + store = AuditLogCursorStore(PROFILE_NAME) + checkpoint = store.get(CURSOR_NAME) + mock_open = mocker.patch("{}.open".format(_NAMESPACE)) + mock_open.side_effect = FileNotFoundError + assert checkpoint is None + + def test_replace_writes_to_expected_file(self, mock_open): + store = AuditLogCursorStore(PROFILE_NAME) + store.replace("checkpointname", 123) + user_path = path.join(path.expanduser("~"), ".code42cli") + expected_path = path.join( + user_path, AUDIT_LOG_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "checkpointname" + ) + mock_open.assert_called_once_with(expected_path, "w") + + def test_replace_writes_expected_content(self, mock_open): + store = AuditLogCursorStore(PROFILE_NAME) + store.replace("checkpointname", 123) + user_path = path.join(path.expanduser("~"), ".code42cli") + path.join( + user_path, AUDIT_LOG_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "checkpointname" + ) + mock_open.return_value.write.assert_called_once_with("123") + + def test_delete_calls_remove_on_expected_file(self, mock_open, mock_remove): + store = AuditLogCursorStore(PROFILE_NAME) + store.delete("deleteme") + user_path = path.join(path.expanduser("~"), ".code42cli") + expected_path = path.join( + user_path, AUDIT_LOG_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, "deleteme" + ) + mock_remove.assert_called_once_with(expected_path) + + def test_delete_when_checkpoint_does_not_exist_raises_cli_error( + self, mock_open, mock_remove + ): + store = AuditLogCursorStore(PROFILE_NAME) + mock_remove.side_effect = FileNotFoundError + with pytest.raises(Code42CLIError): + store.delete("deleteme") + + def test_clean_calls_remove_on_each_checkpoint( + self, mock_open, mock_remove, mock_listdir, mock_isfile + ): + mock_listdir.return_value = ["fileone", "filetwo", "filethree"] + store = AuditLogCursorStore(PROFILE_NAME) + store.clean() + assert mock_remove.call_count == 3 + + def test_get_all_cursors_returns_all_checkpoints(self, mock_listdir, mock_isfile): + mock_listdir.return_value = ["fileone", "filetwo", "filethree"] + store = AuditLogCursorStore(PROFILE_NAME) + cursors = store.get_all_cursors() + assert len(cursors) == 3 + assert cursors[0].name == "fileone" + assert cursors[1].name == "filetwo" + assert cursors[2].name == "filethree" + + def test_get_events_returns_expected_list(self, mock_open_events): + store = AuditLogCursorStore(PROFILE_NAME) + event_list = store.get_events(CURSOR_NAME) + assert event_list == [AUDIT_LOG_EVENT_HASH_1, AUDIT_LOG_EVENT_HASH_2] + + def test_get_events_reads_expected_file(self, mock_open): + store = AuditLogCursorStore(PROFILE_NAME) + store.get_events(CURSOR_NAME) + user_path = path.join(path.expanduser("~"), ".code42cli") + expected_filename = CURSOR_NAME + "_events" + expected_path = path.join( + user_path, AUDIT_LOG_CHECKPOINT_FOLDER_NAME, PROFILE_NAME, expected_filename + ) + mock_open.assert_called_once_with(expected_path) + + def test_get_events_when_profile_does_not_exist_returns_empty_list(self, mocker): + store = AuditLogCursorStore(PROFILE_NAME) + event_list = store.get_events(CURSOR_NAME) + mock_open = mocker.patch("{}.open".format(_NAMESPACE)) + mock_open.side_effect = FileNotFoundError + assert event_list == [] + + def test_get_events_when_checkpoint_not_valid_json_returns_empty_list(self, mocker): + mocker.patch("builtins.open", mocker.mock_open(read_data="invalid_json")) + store = AuditLogCursorStore(PROFILE_NAME) + event_list = store.get_events(CURSOR_NAME) + assert event_list == [] + + def test_replace_events_writes_to_expected_file(self, mock_open): + store = AuditLogCursorStore(PROFILE_NAME) + store.replace_events("checkpointname", ["hash1", "hash2"]) + user_path = path.join(path.expanduser("~"), ".code42cli") + expected_path = path.join( + user_path, + AUDIT_LOG_CHECKPOINT_FOLDER_NAME, + PROFILE_NAME, + "checkpointname_events", + ) + mock_open.assert_called_once_with(expected_path, "w") + + def test_replace_events_writes_expected_content(self, mock_open_events): + store = AuditLogCursorStore(PROFILE_NAME) + store.replace_events("checkpointname", ["hash1", "hash2"]) + user_path = path.join(path.expanduser("~"), ".code42cli") + path.join( + user_path, + AUDIT_LOG_CHECKPOINT_FOLDER_NAME, + PROFILE_NAME, + "checkpointname_events", + ) + mock_open_events.return_value.write.assert_called_once_with( + '["hash1", "hash2"]' + ) diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index b851323ec..bea8aec9c 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -1,13 +1,88 @@ +import json from datetime import datetime from datetime import timedelta +from logging import Logger import pytest from py42.response import Py42Response from requests import Response +from code42cli.cmds.auditlogs import _parse_audit_log_timestamp_string_to_timestamp +from code42cli.cmds.search.cursor_store import AuditLogCursorStore from code42cli.date_helper import parse_max_timestamp from code42cli.date_helper import parse_min_timestamp from code42cli.main import cli +from code42cli.util import hash_event + +TEST_AUDIT_LOG_TIMESTAMP_1 = "2020-01-01T12:00:00.000Z" +TEST_AUDIT_LOG_TIMESTAMP_2 = "2020-02-01T12:01:00.000111Z" +TEST_AUDIT_LOG_TIMESTAMP_3 = "2020-03-01T02:00:00.123456Z" +CURSOR_TIMESTAMP = _parse_audit_log_timestamp_string_to_timestamp( + TEST_AUDIT_LOG_TIMESTAMP_3 +) +TEST_EVENTS_WITH_SAME_TIMESTAMP = [ + { + "type$": "audit_log::logged_in/1", + "actorId": "42", + "actorName": "42@code42.com", + "actorAgent": "py42 python code42cli", + "actorIpAddress": "200.100.300.42", + "timestamp": TEST_AUDIT_LOG_TIMESTAMP_1, + }, + { + "type$": "audit_log::logged_in/1", + "actorId": "43", + "actorName": "43@code42.com", + "actorAgent": "py42 python code42cli", + "actorIpAddress": "200.100.300.42", + "timestamp": TEST_AUDIT_LOG_TIMESTAMP_1, + }, +] +TEST_HIGHEST_TIMESTAMP = "2020-03-01T02:00:00.123456Z" +TEST_EVENTS_WITH_DIFFERENT_TIMESTAMPS = [ + { + "type$": "audit_log::logged_in/1", + "actorId": "44", + "actorName": "44@code42.com", + "actorAgent": "py42 python code42cli", + "actorIpAddress": "200.100.300.42", + "timestamp": TEST_AUDIT_LOG_TIMESTAMP_2, + }, + { + "type$": "audit_log::logged_in/1", + "actorId": "45", + "actorName": "45@code42.com", + "actorAgent": "py42 python code42cli", + "actorIpAddress": "200.100.300.42", + "timestamp": TEST_AUDIT_LOG_TIMESTAMP_3, + }, +] +TEST_CHECKPOINT_EVENT_HASHLIST = [ + hash_event(event) for event in TEST_EVENTS_WITH_SAME_TIMESTAMP +] + + +@pytest.fixture +def audit_log_cursor_with_checkpoint(mocker): + mock_cursor = mocker.MagicMock(spec=AuditLogCursorStore) + mock_cursor.get.return_value = CURSOR_TIMESTAMP + mocker.patch( + "code42cli.cmds.auditlogs._get_audit_log_cursor_store", return_value=mock_cursor + ) + return mock_cursor + + +@pytest.fixture +def audit_log_cursor_with_checkpoint_and_events(mocker): + mock_cursor = mocker.MagicMock(spec=AuditLogCursorStore) + mock_cursor.get.return_value = CURSOR_TIMESTAMP + mock_cursor.get_events.return_value = [ + hash_event(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) + ] + mocker.patch( + "code42cli.cmds.auditlogs._get_audit_log_cursor_store", return_value=mock_cursor + ) + return mock_cursor @pytest.fixture @@ -16,6 +91,48 @@ def date_str(): return dt.strftime("%Y-%m-%d %H:%M:%S") +@pytest.fixture +def send_to_logger(mocker): + mock_logger = mocker.MagicMock(spec=Logger) + mocker.patch( + "code42cli.cmds.auditlogs.get_logger_for_server", return_value=mock_logger + ) + return mock_logger + + +@pytest.fixture +def test_audit_log_response(mocker): + http_response1 = mocker.MagicMock(spec=Response) + http_response1.status_code = 200 + http_response1.text = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) + http_response1._content_consumed = "" + + http_response2 = mocker.MagicMock(spec=Response) + http_response2.status_code = 200 + http_response2.text = json.dumps({"events": TEST_EVENTS_WITH_DIFFERENT_TIMESTAMPS}) + http_response2._content_consumed = "" + Py42Response(http_response2) + + def response_gen(): + yield Py42Response(http_response1) + yield Py42Response(http_response2) + + return response_gen() + + +@pytest.fixture +def test_audit_log_response_with_only_same_timestamps(mocker): + http_response = mocker.MagicMock(spec=Response) + http_response.status_code = 200 + http_response.text = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) + http_response._content_consumed = "" + + def response_gen(): + yield Py42Response(http_response) + + return response_gen() + + def test_search_audit_logs_json_format(runner, cli_state, date_str): runner.invoke(cli, ["audit-logs", "search", "-b", date_str], obj=cli_state) assert cli_state.sdk.auditlogs.get_all.call_count == 1 @@ -92,15 +209,214 @@ def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_st ) -def test_send_to_makes_call_to_the_extract_method( - cli_state, runner, event_extractor_logger, mocker +def test_send_to_makes_expected_call_count_to_the_logger_method( + cli_state, runner, send_to_logger, test_audit_log_response ): + cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + runner.invoke( + cli, ["audit-logs", "send-to", "localhost", "--begin", "1d"], obj=cli_state + ) + assert send_to_logger.info.call_count == 4 - http_response = mocker.MagicMock(spec=Response) - http_response.text = '{"events": [{"property": "bar"}]}' - py42_response = Py42Response(http_response) - cli_state.sdk.auditlogs.get_all.return_value = [py42_response] + +def test_send_to_emits_events_in_chronological_order( + cli_state, runner, send_to_logger, test_audit_log_response +): + cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response runner.invoke( cli, ["audit-logs", "send-to", "localhost", "--begin", "1d"], obj=cli_state ) - assert cli_state.sdk.auditlogs.get_all.call_count == 1 + assert ( + send_to_logger.info.call_args_list[0][0][0]["timestamp"] + == TEST_AUDIT_LOG_TIMESTAMP_1 + ) + assert ( + send_to_logger.info.call_args_list[1][0][0]["timestamp"] + == TEST_AUDIT_LOG_TIMESTAMP_1 + ) + assert ( + send_to_logger.info.call_args_list[2][0][0]["timestamp"] + == TEST_AUDIT_LOG_TIMESTAMP_2 + ) + assert ( + send_to_logger.info.call_args_list[3][0][0]["timestamp"] + == TEST_AUDIT_LOG_TIMESTAMP_3 + ) + + +def test_search_with_checkpoint_saves_expected_cursor_timestamp( + cli_state, runner, test_audit_log_response, audit_log_cursor_with_checkpoint +): + cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + runner.invoke( + cli, + ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, + ) + assert audit_log_cursor_with_checkpoint.replace.call_count == 4 + assert audit_log_cursor_with_checkpoint.replace.call_args_list[3][0] == ( + "test", + CURSOR_TIMESTAMP, + ) + + +def test_send_to_with_checkpoint_saves_expected_cursor_timestamp( + cli_state, + runner, + test_audit_log_response, + audit_log_cursor_with_checkpoint, + send_to_logger, +): + cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "localhost", + "--begin", + "1d", + "--use-checkpoint", + "test", + ], + obj=cli_state, + ) + assert audit_log_cursor_with_checkpoint.replace.call_count == 4 + assert audit_log_cursor_with_checkpoint.replace.call_args_list[3][0] == ( + "test", + CURSOR_TIMESTAMP, + ) + + +def test_search_with_existing_checkpoint_replaces_begin_arg_if_passed( + cli_state, runner, test_audit_log_response, audit_log_cursor_with_checkpoint +): + runner.invoke( + cli, + ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, + ) + assert ( + cli_state.sdk.auditlogs.get_all.call_args[1]["begin_time"] == CURSOR_TIMESTAMP + ) + + +def test_send_to_with_existing_checkpoint_replaces_begin_arg_if_passed( + cli_state, runner, test_audit_log_response, audit_log_cursor_with_checkpoint +): + runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "localhost", + "--begin", + "1d", + "--use-checkpoint", + "test", + ], + obj=cli_state, + ) + assert ( + cli_state.sdk.auditlogs.get_all.call_args[1]["begin_time"] == CURSOR_TIMESTAMP + ) + + +def test_search_with_existing_checkpoint_events_skips_duplicate_events( + cli_state, + runner, + test_audit_log_response, + audit_log_cursor_with_checkpoint_and_events, +): + cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + result = runner.invoke( + cli, + ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, + ) + assert "42@code42.com" not in result.stdout + assert "43@code42.com" in result.stdout + + +def test_send_to_with_existing_checkpoint_events_skips_duplicate_events( + cli_state, + runner, + test_audit_log_response, + audit_log_cursor_with_checkpoint_and_events, + send_to_logger, +): + cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "localhost", + "--begin", + "1d", + "--use-checkpoint", + "test", + ], + obj=cli_state, + ) + assert send_to_logger.info.call_count == 3 + assert send_to_logger.info.call_args_list[0][0][0]["actorName"] != "42@code42.com" + + +def test_search_without_existing_checkpoint_writes_both_event_hashes_with_same_timestamp( + cli_state, + runner, + test_audit_log_response_with_only_same_timestamps, + audit_log_cursor_with_checkpoint, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + test_audit_log_response_with_only_same_timestamps + ) + runner.invoke( + cli, + ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, + ) + assert audit_log_cursor_with_checkpoint.replace_events.call_count == 2 + assert audit_log_cursor_with_checkpoint.replace_events.call_args_list[1][0][1] == [ + hash_event(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]), + hash_event(TEST_EVENTS_WITH_SAME_TIMESTAMP[1]), + ] + + +def test_send_to_without_existing_checkpoint_writes_both_event_hashes_with_same_timestamp( + cli_state, + runner, + test_audit_log_response_with_only_same_timestamps, + audit_log_cursor_with_checkpoint, + send_to_logger, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + test_audit_log_response_with_only_same_timestamps + ) + runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "localhost", + "--begin", + "1d", + "--use-checkpoint", + "test", + ], + obj=cli_state, + ) + assert audit_log_cursor_with_checkpoint.replace_events.call_count == 2 + assert audit_log_cursor_with_checkpoint.replace_events.call_args_list[1][0][1] == [ + hash_event(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]), + hash_event(TEST_EVENTS_WITH_SAME_TIMESTAMP[1]), + ] + + +def test_audit_log_parse_timestamp_handles_possible_strings(): + TIMESTAMP_WITH_MILLISECONDS = "2020-01-01T12:00:00.000Z" + TIMESTAMP_WITHOUT_MILLISECONDS = "2020-01-01T12:00:00Z" + ts1 = _parse_audit_log_timestamp_string_to_timestamp(TIMESTAMP_WITH_MILLISECONDS) + ts2 = _parse_audit_log_timestamp_string_to_timestamp(TIMESTAMP_WITHOUT_MILLISECONDS) + assert ts1 == ts2 diff --git a/tests/test_profile.py b/tests/test_profile.py index aafb7a7c2..b587cd7c9 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -5,6 +5,7 @@ from .conftest import MockSection from code42cli import PRODUCT_NAME from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.cmds.search.cursor_store import AuditLogCursorStore from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.config import ConfigAccessor from code42cli.config import NoConfigProfileError @@ -236,10 +237,12 @@ def test_delete_profile_clears_checkpoints(config_accessor, mocker): mock_get_profile.return_value = profile event_store = mocker.MagicMock(spec=FileEventCursorStore) alert_store = mocker.MagicMock(spec=AlertCursorStore) + auditlog_store = mocker.MagicMock(spec=AuditLogCursorStore) mock_get_cursor_store = mocker.patch( "code42cli.profile.get_all_cursor_stores_for_profile" ) - mock_get_cursor_store.return_value = [event_store, alert_store] + mock_get_cursor_store.return_value = [event_store, alert_store, auditlog_store] cliprofile.delete_profile("deleteme") assert event_store.clean.call_count == 1 assert alert_store.clean.call_count == 1 + assert auditlog_store.clean.call_count == 1 From 715f0de282aaba7f198a428d5416ae4f797d114e Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 16 Dec 2020 11:25:40 -0600 Subject: [PATCH 143/349] example event type options (#180) --- src/code42cli/cmds/auditlogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index b5ad6961d..98c33fb6a 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -61,7 +61,7 @@ filter_option_event_types = click.option( "--event-type", required=False, - help="Filter results by event types.", + help="Filter results by event types (e.g. search_issued, user_registered, user_deactivated).", multiple=True, ) From 203ce1c05c55ff61952f341f181fac8b418a3be0 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 16 Dec 2020 13:28:02 -0600 Subject: [PATCH 144/349] Add missing doc str periods (#181) --- src/code42cli/cmds/departing_employee.py | 2 +- src/code42cli/cmds/high_risk_employee.py | 10 +++++----- src/code42cli/cmds/securitydata.py | 3 ++- src/code42cli/options.py | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 7d52087c3..8cb0167b7 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -76,7 +76,7 @@ def bulk(state): @bulk.command( name="add", help="Bulk add users to the departing employees detection list using a CSV file with " - "format: {}".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)), + "format: {}.".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @sdk_options() diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index dc5708a1f..85e48f7b7 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -103,7 +103,7 @@ def bulk(state): @bulk.command( name="add", help="Bulk add users to the high risk employees detection list using a CSV file with " - "format: {}".format(",".join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)), + "format: {}.".format(",".join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) @sdk_options() @@ -122,8 +122,8 @@ def handle_row(username, cloud_alias, risk_tag, notes): @bulk.command( name="remove", - help="Bulk remove users from the high risk employees detection list using a line-separated " - "file of usernames.", + help="Bulk remove users from the high risk employees detection list using a line-separated file " + "of usernames.", ) @read_flat_file_arg @sdk_options() @@ -142,7 +142,7 @@ def handle_row(username): @bulk.command( name="add-risk-tags", - help="Adds risk tags to users in bulk using a CSV file with format: {}".format( + help="Adds risk tags to users in bulk using a CSV file with format: {}.".format( ",".join(RISK_TAG_CSV_HEADERS) ), ) @@ -161,7 +161,7 @@ def handle_row(username, tag): @bulk.command( name="remove-risk-tags", - help="Removes risk tags from users in bulk using a CSV file with format: {}".format( + help="Removes risk tags from users in bulk using a CSV file with format: {}.".format( ",".join(RISK_TAG_CSV_HEADERS) ), ) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 705105e81..5bc0b3233 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -127,7 +127,8 @@ multiple=True, callback=searchopt.is_in_filter(f.ProcessOwner), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, - help="Limits exposure events by process owner, as reported by the device’s operating system. Applies only to `Printed` and `Browser or app read` events", + help="Limits exposure events by process owner, as reported by the device’s operating system. " + "Applies only to `Printed` and `Browser or app read` events.", ) tab_url_option = click.option( "--tab-url", diff --git a/src/code42cli/options.py b/src/code42cli/options.py index cfe8599af..48b2352ca 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -145,7 +145,7 @@ def server_options(f): "--protocol", type=click.Choice(ServerProtocol(), case_sensitive=False), default=ServerProtocol.UDP, - help="Protocol used to send logs to server. Defaults to UDP", + help="Protocol used to send logs to server. Defaults to UDP.", ) f = hostname_arg(f) f = protocol_option(f) From 0b567f0d34619280fa03b795f188d58deb89ac88 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Dec 2020 11:06:57 -0600 Subject: [PATCH 145/349] Change/user to actor al (#183) --- src/code42cli/cmds/auditlogs.py | 36 +++++++++++++++++++-------------- tests/cmds/test_auditlogs.py | 12 +++++------ 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index 98c33fb6a..ddbbd4e45 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -34,14 +34,20 @@ filter_option_usernames = click.option( - "--username", required=False, help="Filter results by usernames.", multiple=True, + "--actor-username", + required=False, + help="Filter results by actor usernames.", + multiple=True, ) filter_option_user_ids = click.option( - "--user-id", required=False, help="Filter results by user ids.", multiple=True, + "--actor-user-id", + required=False, + help="Filter results by actor user ids.", + multiple=True, ) filter_option_user_ip_addresses = click.option( - "--user-ip", + "--actor-ip", required=False, help="Filter results by user ip addresses.", multiple=True, @@ -119,9 +125,9 @@ def search( begin, end, event_type, - username, - user_id, - user_ip, + actor_username, + actor_user_id, + actor_ip, affected_user_id, affected_username, format, @@ -141,9 +147,9 @@ def search( begin_time=begin, end_time=end, event_types=event_type, - usernames=username, - user_ids=user_id, - user_ip_addresses=user_ip, + usernames=actor_username, + user_ids=actor_user_id, + user_ip_addresses=actor_ip, affected_user_ids=affected_user_id, affected_usernames=affected_username, ) @@ -177,9 +183,9 @@ def send_to( begin, end, event_type, - username, - user_id, - user_ip, + actor_username, + actor_user_id, + actor_ip, affected_user_id, affected_username, use_checkpoint, @@ -198,9 +204,9 @@ def send_to( begin_time=begin, end_time=end, event_types=event_type, - usernames=username, - user_ids=user_id, - user_ip_addresses=user_ip, + usernames=actor_username, + user_ids=actor_user_id, + user_ip_addresses=actor_ip, affected_user_ids=affected_user_id, affected_usernames=affected_username, ) diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index bea8aec9c..dab5f0022 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -144,9 +144,9 @@ def test_search_audit_logs_with_filter_parameters(runner, cli_state, date_str): [ "audit-logs", "search", - "--username", + "--actor-username", "test@test.com", - "--username", + "--actor-username", "test2@test.test", "--begin", date_str, @@ -173,13 +173,13 @@ def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_st [ "audit-logs", "search", - "--username", + "--actor-username", "test@test.com", - "--username", + "--actor-username", "test2@test.test", "--event-type", "saved-search", - "--user-ip", + "--actor-ip", "0.0.0.0", "--affected-username", "test@test.test", @@ -187,7 +187,7 @@ def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_st "123", "--affected-user-id", "456", - "--user-id", + "--actor-user-id", "userid", "-b", date_str, From 36c0e47272ef2ef3060b200c1c8fd16e23625ae9 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 18 Dec 2020 11:15:28 -0600 Subject: [PATCH 146/349] remove formatting options from `audit-logs send-to` (#182) * remove formatting options from `audit-logs send-to` * use RAW-JSON --- src/code42cli/cmds/auditlogs.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index ddbbd4e45..db7a6c690 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -14,7 +14,6 @@ from code42cli.options import end_option from code42cli.options import format_option from code42cli.options import sdk_options -from code42cli.options import send_to_format_options from code42cli.options import server_options from code42cli.output_formats import OutputFormatter from code42cli.util import hash_event @@ -173,13 +172,11 @@ def search( @filter_options @checkpoint_option @server_options -@send_to_format_options @sdk_options() def send_to( state, hostname, protocol, - format, begin, end, event_type, @@ -190,8 +187,8 @@ def send_to( affected_username, use_checkpoint, ): - """Send audit logs to the given server address.""" - logger = get_logger_for_server(hostname, protocol, format) + """Send audit logs to the given server address in JSON format.""" + logger = get_logger_for_server(hostname, protocol, "RAW-JSON") cursor = _get_audit_log_cursor_store(state.profile.name) if use_checkpoint: checkpoint_name = use_checkpoint From b433ff0f620e0e12e218d9eb2dca8bc6ba1e9a58 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Dec 2020 11:17:21 -0600 Subject: [PATCH 147/349] Auditlogs docs (#184) --- docs/commands.md | 1 + docs/commands/auditlogs.rst | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 docs/commands/auditlogs.rst diff --git a/docs/commands.md b/docs/commands.md index a0fe8adef..adb292a39 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,6 +2,7 @@ * [Profile](commands/profile.rst) * [Security Data](commands/securitydata.rst) +* [Audit Logs](commands/auditlogs.rst) * [Alerts](commands/alerts.rst) * [Alert Rules](commands/alertrules.rst) * [Departing Employee](commands/departingemployee.rst) diff --git a/docs/commands/auditlogs.rst b/docs/commands/auditlogs.rst new file mode 100644 index 000000000..9897081d2 --- /dev/null +++ b/docs/commands/auditlogs.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.auditlogs:audit_logs + :prog: alerts + :show-nested: From c024086726b9ed1eabe52605aa8673e6f0f9dbd4 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Dec 2020 11:25:16 -0600 Subject: [PATCH 148/349] Auditlogsdocs fix (#185) --- docs/commands/auditlogs.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands/auditlogs.rst b/docs/commands/auditlogs.rst index 9897081d2..f8ee5921d 100644 --- a/docs/commands/auditlogs.rst +++ b/docs/commands/auditlogs.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.auditlogs:audit_logs - :prog: alerts + :prog: auditlogs :show-nested: From e50e0b5bf4f6887bb3a10067e2ab7ce394476d18 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Dec 2020 11:33:14 -0600 Subject: [PATCH 149/349] bumps (#186) --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd7c4c5b..77762f46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.1.0 - 2020-12-18 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 5becc17c0..6849410aa 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.1.0" From 604e66503de3c44e0e672f41ad733ac6c8ddb164 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Dec 2020 11:50:50 -0600 Subject: [PATCH 150/349] force 38 (#187) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index df7361937..fbb3a54e3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v1 with: - python-version: '3.x' + python-version: '3.8' - name: Install dependencies run: | python -m pip install --upgrade pip From aecbef8402595d02eb95fca82405af503f5472d6 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Dec 2020 12:06:36 -0600 Subject: [PATCH 151/349] savE (#188) --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fbb3a54e3..64645e65d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,7 +25,7 @@ jobs: src_file=( ./dist/*.tar.gz ) wheel_file=( ./dist/*.whl ) echo "RELEASE_ID=$(jq --raw-output '.release.id' $GITHUB_EVENT_PATH)" >> $GITHUB_ENV - echo "SOURCE_DIST_FILE=$(jq --raw-output '.release.id' $GITHUB_EVENT_PATH)" >> $GITHUB_ENV + echo "SOURCE_DIST_FILE=$(basename $src_file)" >> $GITHUB_ENV echo "WHEEL_FILE=$(basename $wheel_file)" >> $GITHUB_ENV - name: Set Upload Url run: | From c374fd200e06cb66c2afcc8dd3f08b4aaca7ff3f Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Dec 2020 12:43:05 -0600 Subject: [PATCH 152/349] Feature/list detection lists (#179) --- CHANGELOG.md | 8 ++ README.md | 2 +- src/code42cli/cmds/departing_employee.py | 34 +++++ src/code42cli/cmds/detectionlists/__init__.py | 42 ++++++ src/code42cli/cmds/high_risk_employee.py | 36 +++++ tests/cmds/conftest.py | 26 ++++ tests/cmds/test_departing_employee.py | 123 ++++++++++++++-- tests/cmds/test_high_risk_employee.py | 132 ++++++++++++++---- 8 files changed, 363 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77762f46d..6aeac9e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +# Unreleased + +# Added + +- `code42 departing-employee list` command. + +- `code42 high-risk-employee list` command. + ## 1.1.0 - 2020-12-18 ### Fixed diff --git a/README.md b/README.md index 642a5cfe5..833e6a8e2 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ code42 security-data search -b 10d -e 12h Begin date will be ignored if provided on subsequent queries using `-c/--use-checkpoint`. -Use different format with `-f`: +Use other formats with `-f`: ```bash code42 security-data search -b 2020-02-02 -f CEF diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 8cb0167b7..51ed6e418 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -1,9 +1,14 @@ import click from py42.exceptions import Py42NotFoundError +from py42.services.detectionlists.departing_employee import DepartingEmployeeFilters from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process from code42cli.click_ext.groups import OrderedGroup +from code42cli.cmds.detectionlists import ALL_FILTER +from code42cli.cmds.detectionlists import get_choices +from code42cli.cmds.detectionlists import handle_filter_choice +from code42cli.cmds.detectionlists import list_employees from code42cli.cmds.detectionlists import update_user from code42cli.cmds.detectionlists.options import cloud_alias_option from code42cli.cmds.detectionlists.options import notes_option @@ -12,10 +17,23 @@ from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.file_readers import read_flat_file_arg +from code42cli.options import format_option from code42cli.options import sdk_options +def _get_filter_choices(): + filters = DepartingEmployeeFilters.choices() + return get_choices(filters) + + DATE_FORMAT = "%Y-%m-%d" +filter_option = click.option( + "--filter", + help="Departing employee filter options. Defaults to {}.".format(ALL_FILTER), + type=click.Choice(_get_filter_choices()), + default=ALL_FILTER, + callback=lambda ctx, param, arg: handle_filter_choice(arg), +) @click.group(cls=OrderedGroup) @@ -25,6 +43,18 @@ def departing_employee(state): pass +@departing_employee.command("list") +@sdk_options() +@format_option +@filter_option +def _list(state, format, filter): + """Lists the employees on the Departing Employee list.""" + employee_generator = _get_departing_employees(state.sdk, filter) + list_employees( + employee_generator, format, {"departureDate": "Departure Date"}, + ) + + @departing_employee.command() @username_arg @click.option( @@ -128,6 +158,10 @@ def handle_row(username): ) +def _get_departing_employees(sdk, filter): + return sdk.detectionlists.departing_employee.get_all(filter) + + def _add_departing_employee(sdk, username, cloud_alias, departure_date, notes): user_id = get_user_id(sdk, username) sdk.detectionlists.departing_employee.add(user_id, departure_date) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 64bf7e044..cb41a195a 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -1,4 +1,46 @@ +import click +from py42.services.detectionlists import _DetectionListFilters + from code42cli.cmds.shared import get_user_id +from code42cli.output_formats import OutputFormat +from code42cli.output_formats import OutputFormatter + + +ALL_FILTER = "ALL" + + +def get_choices(filters): + filters.remove(_DetectionListFilters.OPEN) + filters.append(ALL_FILTER) + return filters + + +def handle_filter_choice(choice): + if choice == ALL_FILTER: + return _DetectionListFilters.OPEN + return choice + + +def list_employees(employee_generator, output_format, additional_header_items=None): + additional_header_items = additional_header_items or {} + header = {"userName": "Username", "notes": "Notes", **additional_header_items} + employee_list = [] + for employees in employee_generator: + for employee in employees["items"]: + if employee["notes"] and output_format == OutputFormat.TABLE: + employee["notes"] = ( + employee["notes"].replace("\n", "\\n").replace("\t", "\\t") + ) + employee_list.append(employee) + if employee_list: + formatter = OutputFormatter(output_format, header) + if len(employee_list) > 10: + output = formatter.get_formatted_output(employee_list) + click.echo_via_pager(output) + else: + formatter.echo_formatted_list(employee_list) + else: + click.echo("No users found.") def update_user(sdk, username, cloud_alias=None, risk_tag=None, notes=None): diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 85e48f7b7..c1aa9c41a 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -1,12 +1,17 @@ import click from py42.clients.detectionlists import RiskTags from py42.exceptions import Py42NotFoundError +from py42.services.detectionlists.high_risk_employee import HighRiskEmployeeFilters from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process from code42cli.click_ext.groups import OrderedGroup from code42cli.cmds.detectionlists import add_risk_tags as _add_risk_tags +from code42cli.cmds.detectionlists import ALL_FILTER +from code42cli.cmds.detectionlists import get_choices +from code42cli.cmds.detectionlists import handle_filter_choice from code42cli.cmds.detectionlists import handle_list_args +from code42cli.cmds.detectionlists import list_employees from code42cli.cmds.detectionlists import remove_risk_tags as _remove_risk_tags from code42cli.cmds.detectionlists import update_user from code42cli.cmds.detectionlists.options import cloud_alias_option @@ -16,8 +21,24 @@ from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.file_readers import read_flat_file_arg +from code42cli.options import format_option from code42cli.options import sdk_options + +def _get_filter_choices(): + filters = HighRiskEmployeeFilters.choices() + return get_choices(filters) + + +filter_option = click.option( + "--filter", + help="High risk employee filter options. Defaults to {}.".format(ALL_FILTER), + type=click.Choice(_get_filter_choices()), + default=ALL_FILTER, + callback=lambda ctx, param, arg: handle_filter_choice(arg), +) + + risk_tag_option = click.option( "-t", "--risk-tag", @@ -34,6 +55,17 @@ def high_risk_employee(state): pass +@high_risk_employee.command("list") +@sdk_options() +@format_option +@filter_option +def _list(state, format, filter): + """Lists the employees on the High Risk Employee list.""" + + employee_generator = _get_high_risk_employees(state.sdk, filter) + list_employees(employee_generator, format) + + @high_risk_employee.command() @cloud_alias_option @notes_option @@ -178,6 +210,10 @@ def handle_row(username, tag): ) +def _get_high_risk_employees(sdk, filter): + return sdk.detectionlists.high_risk_employee.get_all(filter) + + def _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes): risk_tag = handle_list_args(risk_tag) user_id = get_user_id(sdk, username) diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 887d87951..a1d6dceda 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -2,9 +2,12 @@ import threading import pytest +from py42.exceptions import Py42NotFoundError from py42.exceptions import Py42UserAlreadyAddedError +from py42.response import Py42Response from py42.sdk import SDKClient from requests import HTTPError +from requests import Request from requests import Response from tests.conftest import convert_str_to_date from tests.conftest import TEST_ID @@ -15,6 +18,18 @@ TEST_EMPLOYEE = "risky employee" +def get_user_not_on_list_side_effect(mocker): + def side_effect(*args, **kwargs): + err = mocker.MagicMock(spec=HTTPError) + resp = mocker.MagicMock(spec=Response) + resp.text = "TEST_ERR" + err.response = resp + err.response.request = mocker.MagicMock(spec=Request) + raise Py42NotFoundError(err) + + return side_effect + + @pytest.fixture def sdk(mocker): return mocker.MagicMock(spec=SDKClient) @@ -93,3 +108,14 @@ def f(*args): f.call_count = 0 f.call_args_list = [] return f + + +def get_generator_for_get_all(mocker, mock_return_items): + mock_return_items = mock_return_items or "" + + def gen(*args, **kwargs): + response = mocker.MagicMock(spec=Request) + response.text = """{{"items": [{0}]}}""".format(mock_return_items) + yield Py42Response(response) + + return gen diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index fb50b2a2a..3480c9566 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -1,7 +1,7 @@ -from py42.exceptions import Py42NotFoundError -from requests import HTTPError -from requests import Request -from requests import Response +import pytest +from py42.services.detectionlists.departing_employee import DepartingEmployeeFilters +from tests.cmds.conftest import get_generator_for_get_all +from tests.cmds.conftest import get_user_not_on_list_side_effect from tests.cmds.conftest import thread_safe_side_effect from tests.conftest import TEST_ID @@ -9,16 +9,111 @@ from code42cli.main import cli -def get_user_not_on_departing_employee_list_side_effect(mocker): - def side_effect(*args, **kwargs): - err = mocker.MagicMock(spec=HTTPError) - resp = mocker.MagicMock(spec=Response) - resp.text = "TEST_ERR" - err.response = resp - err.response.request = mocker.MagicMock(spec=Request) - raise Py42NotFoundError(err) +DEPARTING_EMPLOYEE_ITEM = """{ + "type$": "DEPARTING_EMPLOYEE_V2", + "tenantId": "1111111-af5b-4231-9d8e-000000000", + "userId": "TEST USER UID", + "userName": "test.testerson@example.com", + "displayName": "Testerson", + "notes": "Leaving for competitor", + "createdAt": "2020-06-23T19:57:37.1345130Z", + "status": "OPEN", + "cloudUsernames": ["cloud@example.com"], + "departureDate": "2020-07-07" +} +""" + + +@pytest.fixture() +def mock_get_all_empty_state(mocker, cli_state_with_user): + generator = get_generator_for_get_all(mocker, None) + cli_state_with_user.sdk.detectionlists.departing_employee.get_all.side_effect = ( + generator + ) + return cli_state_with_user + + +@pytest.fixture() +def mock_get_all_state(mocker, cli_state_with_user): + generator = get_generator_for_get_all(mocker, DEPARTING_EMPLOYEE_ITEM) + cli_state_with_user.sdk.detectionlists.departing_employee.get_all.side_effect = ( + generator + ) + return cli_state_with_user + + +def test_list_departing_employees_lists_expected_properties(runner, mock_get_all_state): + res = runner.invoke(cli, ["departing-employee", "list"], obj=mock_get_all_state) + assert "Username" in res.output + assert "Notes" in res.output + assert "test.testerson@example.com" in res.output + assert "Leaving for competitor" in res.output + assert "Departure Date" in res.output + assert "2020-07-07" in res.output + + +def test_list_departing_employees_converts_all_to_open(runner, mock_get_all_state): + runner.invoke( + cli, ["departing-employee", "list", "--filter", "ALL"], obj=mock_get_all_state + ) + mock_get_all_state.sdk.detectionlists.departing_employee.get_all.assert_called_once_with( + DepartingEmployeeFilters.OPEN + ) + + +def test_list_departing_employees_when_given_raw_json_lists_expected_properties( + runner, mock_get_all_state +): + res = runner.invoke( + cli, ["departing-employee", "list", "-f", "RAW-JSON"], obj=mock_get_all_state + ) + assert "userName" in res.output + assert "notes" in res.output + assert "test.testerson@example.com" in res.output + assert "Leaving for competitor" in res.output + assert "cloudUsernames" in res.output + assert "cloud@example.com" in res.output + assert "departureDate" in res.output + assert "2020-07-07" in res.output + + +def test_list_departing_employees_when_no_employees_echos_expected_message( + runner, mock_get_all_empty_state +): + res = runner.invoke( + cli, ["departing-employee", "list"], obj=mock_get_all_empty_state + ) + assert "No users found." in res.output + + +def test_list_departing_employees_when_table_format_and_notes_contains_newlines_escapes_them( + runner, mocker, cli_state_with_user +): + new_line_text = str(DEPARTING_EMPLOYEE_ITEM).replace( + "Leaving for competitor", r"Line1\nLine2" + ) + generator = get_generator_for_get_all(mocker, new_line_text) + cli_state_with_user.sdk.detectionlists.departing_employee.get_all.side_effect = ( + generator + ) + res = runner.invoke(cli, ["departing-employee", "list"], obj=cli_state_with_user) + assert "Line1\\nLine2" in res.output + - return side_effect +def test_list_departing_employees_uses_filter_option(runner, mock_get_all_state): + runner.invoke( + cli, + [ + "departing-employee", + "list", + "--filter", + DepartingEmployeeFilters.EXFILTRATION_30_DAYS, + ], + obj=mock_get_all_state, + ) + mock_get_all_state.sdk.detectionlists.departing_employee.get_all.assert_called_once_with( + DepartingEmployeeFilters.EXFILTRATION_30_DAYS + ) def test_add_departing_employee_when_given_cloud_alias_adds_alias( @@ -213,7 +308,7 @@ def test_add_departing_employee_when_invalid_date_format_validation_raises_error def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( mocker, runner, cli_state ): - cli_state.sdk.detectionlists.departing_employee.remove.side_effect = get_user_not_on_departing_employee_list_side_effect( + cli_state.sdk.detectionlists.departing_employee.remove.side_effect = get_user_not_on_list_side_effect( mocker ) test_username = "test@example.com" diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 2228b7f71..4094940f5 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -1,7 +1,7 @@ -from py42.exceptions import Py42NotFoundError -from requests import HTTPError -from requests import Request -from requests import Response +import pytest +from py42.services.detectionlists.high_risk_employee import HighRiskEmployeeFilters +from tests.cmds.conftest import get_generator_for_get_all +from tests.cmds.conftest import get_user_not_on_list_side_effect from tests.cmds.conftest import TEST_EMPLOYEE from tests.cmds.conftest import thread_safe_side_effect from tests.conftest import TEST_ID @@ -11,16 +11,108 @@ _NAMESPACE = "code42cli.cmds.high_risk_employee" -def get_user_not_on_high_risk_employee_list_side_effect(mocker): - def side_effect(*args, **kwargs): - err = mocker.MagicMock(spec=HTTPError) - resp = mocker.MagicMock(spec=Response) - resp.text = "TEST_ERR" - err.response = resp - err.response.request = mocker.MagicMock(spec=Request) - raise Py42NotFoundError(err) +HIGH_RISK_EMPLOYEE_ITEM = """{ + "type$": "HIGH_RISK_EMPLOYEE_V2", + "tenantId": "1111111-af5b-4231-9d8e-000000000", + "userId": "TEST USER UID", + "userName": "test.testerson@example.com", + "displayName": "Testerson", + "notes": "Leaving for competitor", + "createdAt": "2020-06-23T19:57:37.1345130Z", + "status": "OPEN", + "cloudUsernames": ["cloud@example.com"], + "riskFactors": ["PERFORMANCE_CONCERNS"] +} +""" + + +@pytest.fixture() +def mock_get_all_empty_state(mocker, cli_state_with_user): + generator = get_generator_for_get_all(mocker, None) + cli_state_with_user.sdk.detectionlists.high_risk_employee.get_all.side_effect = ( + generator + ) + return cli_state_with_user + + +@pytest.fixture() +def mock_get_all_state(mocker, cli_state_with_user): + generator = get_generator_for_get_all(mocker, HIGH_RISK_EMPLOYEE_ITEM) + cli_state_with_user.sdk.detectionlists.high_risk_employee.get_all.side_effect = ( + generator + ) + return cli_state_with_user + + +def test_list_high_risk_employees_lists_expected_properties(runner, mock_get_all_state): + res = runner.invoke(cli, ["high-risk-employee", "list"], obj=mock_get_all_state) + assert "Username" in res.output + assert "Notes" in res.output + assert "test.testerson@example.com" in res.output + + +def test_list_departing_employees_converts_all_to_open(runner, mock_get_all_state): + runner.invoke( + cli, ["high-risk-employee", "list", "--filter", "ALL"], obj=mock_get_all_state + ) + mock_get_all_state.sdk.detectionlists.high_risk_employee.get_all.assert_called_once_with( + HighRiskEmployeeFilters.OPEN + ) - return side_effect + +def test_list_high_risk_employees_when_given_raw_json_lists_expected_properties( + runner, mock_get_all_state +): + res = runner.invoke( + cli, ["high-risk-employee", "list", "-f", "RAW-JSON"], obj=mock_get_all_state + ) + assert "userName" in res.output + assert "notes" in res.output + assert "test.testerson@example.com" in res.output + assert "Leaving for competitor" in res.output + assert "cloudUsernames" in res.output + assert "cloud@example.com" in res.output + assert "riskFactors" in res.output + assert "PERFORMANCE_CONCERNS" in res.output + + +def test_list_high_risk_employees_when_no_employees_echos_expected_message( + runner, mock_get_all_empty_state +): + res = runner.invoke( + cli, ["high-risk-employee", "list"], obj=mock_get_all_empty_state + ) + assert "No users found." in res.output + + +def test_list_high_risk_employees_uses_filter_option(runner, mock_get_all_state): + runner.invoke( + cli, + [ + "high-risk-employee", + "list", + "--filter", + HighRiskEmployeeFilters.EXFILTRATION_30_DAYS, + ], + obj=mock_get_all_state, + ) + mock_get_all_state.sdk.detectionlists.high_risk_employee.get_all.assert_called_once_with( + HighRiskEmployeeFilters.EXFILTRATION_30_DAYS, + ) + + +def test_list_high_risk_employees_when_table_format_and_notes_contains_newlines_escapes_them( + runner, mocker, cli_state_with_user +): + new_line_text = str(HIGH_RISK_EMPLOYEE_ITEM).replace( + "Leaving for competitor", r"Line1\nLine2" + ) + generator = get_generator_for_get_all(mocker, new_line_text) + cli_state_with_user.sdk.detectionlists.high_risk_employee.get_all.side_effect = ( + generator + ) + res = runner.invoke(cli, ["high-risk-employee", "list"], obj=cli_state_with_user) + assert "Line1\\nLine2" in res.output def test_add_high_risk_employee_adds(runner, cli_state_with_user): @@ -95,7 +187,7 @@ def test_add_high_risk_employee_when_user_does_not_exist_exits_with_correct_mess def test_add_high_risk_employee_when_user_already_added_exits_with_correct_message( - mocker, runner, cli_state_with_user, user_already_added_error + runner, cli_state_with_user, user_already_added_error ): def add_user(user): raise user_already_added_error @@ -128,16 +220,6 @@ def test_remove_high_risk_employee_when_user_does_not_exist_exits_with_correct_m assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output -def test_generate_template_file_when_given_add_generates_template_from_handler( - runner, cli_state -): - pass - - -def test_generate_template_file_when_given_remove_generates_template_from_handler(): - pass - - def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state): add_user_cloud_alias = thread_safe_side_effect() add_user_risk_tags = thread_safe_side_effect() @@ -240,7 +322,7 @@ def test_bulk_remove_risk_tags_uses_expected_arguments(runner, cli_state, mocker def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( mocker, runner, cli_state ): - cli_state.sdk.detectionlists.high_risk_employee.remove.side_effect = get_user_not_on_high_risk_employee_list_side_effect( + cli_state.sdk.detectionlists.high_risk_employee.remove.side_effect = get_user_not_on_list_side_effect( mocker ) test_username = "test@example.com" From 39bdfc40e0edf6e2600e73a1abf279372c6e6f28 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Dec 2020 12:50:16 -0600 Subject: [PATCH 153/349] Chore/rm dlrm errs (#178) --- CHANGELOG.md | 5 +++++ setup.py | 2 +- src/code42cli/click_ext/groups.py | 2 ++ src/code42cli/cmds/departing_employee.py | 10 +--------- src/code42cli/cmds/high_risk_employee.py | 11 +---------- tests/cmds/conftest.py | 6 +++--- tests/cmds/test_departing_employee.py | 6 +++--- tests/cmds/test_high_risk_employee.py | 6 +++--- 8 files changed, 19 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aeac9e65..0db5f3dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 high-risk-employee list` command. +### Changed + +- The error text when removing an employee from a detection list now references the employee + by ID rather the username. + ## 1.1.0 - 2020-12-18 ### Fixed diff --git a/setup.py b/setup.py index afce8924b..fc2c66c0d 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ "c42eventextractor==0.4.0", "keyring==18.0.1", "keyrings.alt==3.2.0", - "py42>=1.9", + "py42>=1.10", ], extras_require={ "dev": [ diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index b366aa03d..3328941f5 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -8,6 +8,7 @@ from py42.exceptions import Py42InvalidRuleOperationError from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError from py42.exceptions import Py42UserAlreadyAddedError +from py42.exceptions import Py42UserNotOnListError from code42cli.errors import Code42CLIError from code42cli.errors import LoggedCLIError @@ -53,6 +54,7 @@ def invoke(self, ctx): except ( UserDoesNotExistError, Py42UserAlreadyAddedError, + Py42UserNotOnListError, Py42InvalidRuleOperationError, Py42LegalHoldNotFoundOrPermissionDeniedError, ) as err: diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 51ed6e418..69c3ffcba 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -1,5 +1,4 @@ import click -from py42.exceptions import Py42NotFoundError from py42.services.detectionlists.departing_employee import DepartingEmployeeFilters from code42cli.bulk import generate_template_cmd_factory @@ -77,14 +76,7 @@ def add(state, username, cloud_alias, departure_date, notes): @sdk_options() def remove(state, username): """Remove a user from the departing-employee detection list.""" - try: - _remove_departing_employee(state.sdk, username) - except Py42NotFoundError: - raise Code42CLIError( - "User {} is not currently on the departing-employee detection list.".format( - username - ) - ) + _remove_departing_employee(state.sdk, username) @departing_employee.group(cls=OrderedGroup) diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index c1aa9c41a..5d87eabac 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -1,6 +1,5 @@ import click from py42.clients.detectionlists import RiskTags -from py42.exceptions import Py42NotFoundError from py42.services.detectionlists.high_risk_employee import HighRiskEmployeeFilters from code42cli.bulk import generate_template_cmd_factory @@ -18,7 +17,6 @@ from code42cli.cmds.detectionlists.options import notes_option from code42cli.cmds.detectionlists.options import username_arg from code42cli.cmds.shared import get_user_id -from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.file_readers import read_flat_file_arg from code42cli.options import format_option @@ -82,14 +80,7 @@ def add(state, username, cloud_alias, risk_tag, notes): @sdk_options() def remove(state, username): """Remove a user from the high risk employees detection list.""" - try: - _remove_high_risk_employee(state.sdk, username) - except Py42NotFoundError: - raise Code42CLIError( - "User {} is not currently on the high-risk-employee detection list.".format( - username - ) - ) + _remove_high_risk_employee(state.sdk, username) @high_risk_employee.command() diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index a1d6dceda..bd0a1e39a 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -2,8 +2,8 @@ import threading import pytest -from py42.exceptions import Py42NotFoundError from py42.exceptions import Py42UserAlreadyAddedError +from py42.exceptions import Py42UserNotOnListError from py42.response import Py42Response from py42.sdk import SDKClient from requests import HTTPError @@ -18,14 +18,14 @@ TEST_EMPLOYEE = "risky employee" -def get_user_not_on_list_side_effect(mocker): +def get_user_not_on_list_side_effect(mocker, list_name): def side_effect(*args, **kwargs): err = mocker.MagicMock(spec=HTTPError) resp = mocker.MagicMock(spec=Response) resp.text = "TEST_ERR" err.response = resp err.response.request = mocker.MagicMock(spec=Request) - raise Py42NotFoundError(err) + raise Py42UserNotOnListError(err, TEST_ID, list_name) return side_effect diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 3480c9566..ef4dba27e 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -309,15 +309,15 @@ def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( mocker, runner, cli_state ): cli_state.sdk.detectionlists.departing_employee.remove.side_effect = get_user_not_on_list_side_effect( - mocker + mocker, "departing-employee" ) test_username = "test@example.com" result = runner.invoke( cli, ["departing-employee", "remove", test_username], obj=cli_state ) assert ( - "User {} is not currently on the departing-employee detection list.".format( - test_username + "User with ID '{}' is not currently on the departing-employee list.".format( + TEST_ID ) in result.output ) diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 4094940f5..73ba2c0c7 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -323,15 +323,15 @@ def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( mocker, runner, cli_state ): cli_state.sdk.detectionlists.high_risk_employee.remove.side_effect = get_user_not_on_list_side_effect( - mocker + mocker, "high-risk-employee" ) test_username = "test@example.com" result = runner.invoke( cli, ["high-risk-employee", "remove", test_username], obj=cli_state ) assert ( - "User {} is not currently on the high-risk-employee detection list.".format( - test_username + "User with ID '{}' is not currently on the high-risk-employee list.".format( + TEST_ID ) in result.output ) From bc8c1c162dd6077a820e19669fef051091a3abef Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 22 Dec 2020 09:00:36 -0600 Subject: [PATCH 154/349] Bugfix/list no notes (#189) --- src/code42cli/cmds/detectionlists/__init__.py | 2 +- tests/cmds/test_departing_employee.py | 16 ++++++++++++++++ tests/cmds/test_high_risk_employee.py | 18 +++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index cb41a195a..3a2bb061c 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -27,7 +27,7 @@ def list_employees(employee_generator, output_format, additional_header_items=No employee_list = [] for employees in employee_generator: for employee in employees["items"]: - if employee["notes"] and output_format == OutputFormat.TABLE: + if employee.get("notes") and output_format == OutputFormat.TABLE: employee["notes"] = ( employee["notes"].replace("\n", "\\n").replace("\t", "\\t") ) diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index ef4dba27e..f70108891 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -1,3 +1,5 @@ +import json + import pytest from py42.services.detectionlists.departing_employee import DepartingEmployeeFilters from tests.cmds.conftest import get_generator_for_get_all @@ -116,6 +118,20 @@ def test_list_departing_employees_uses_filter_option(runner, mock_get_all_state) ) +def test_list_departing_employees_handles_employees_with_no_notes( + runner, mocker, cli_state_with_user +): + hr_json = json.loads(DEPARTING_EMPLOYEE_ITEM) + hr_json["notes"] = None + new_text = json.dumps(hr_json) + generator = get_generator_for_get_all(mocker, new_text) + cli_state_with_user.sdk.detectionlists.departing_employee.get_all.side_effect = ( + generator + ) + res = runner.invoke(cli, ["departing-employee", "list"], obj=cli_state_with_user) + assert "None" in res.output + + def test_add_departing_employee_when_given_cloud_alias_adds_alias( runner, cli_state_with_user ): diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 73ba2c0c7..8818185cf 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -1,3 +1,5 @@ +import json + import pytest from py42.services.detectionlists.high_risk_employee import HighRiskEmployeeFilters from tests.cmds.conftest import get_generator_for_get_all @@ -51,7 +53,7 @@ def test_list_high_risk_employees_lists_expected_properties(runner, mock_get_all assert "test.testerson@example.com" in res.output -def test_list_departing_employees_converts_all_to_open(runner, mock_get_all_state): +def test_list_high_risk_employees_converts_all_to_open(runner, mock_get_all_state): runner.invoke( cli, ["high-risk-employee", "list", "--filter", "ALL"], obj=mock_get_all_state ) @@ -115,6 +117,20 @@ def test_list_high_risk_employees_when_table_format_and_notes_contains_newlines_ assert "Line1\\nLine2" in res.output +def test_list_high_risk_employees_handles_employees_with_no_notes( + runner, mocker, cli_state_with_user +): + hr_json = json.loads(HIGH_RISK_EMPLOYEE_ITEM) + hr_json["notes"] = None + new_text = json.dumps(hr_json) + generator = get_generator_for_get_all(mocker, new_text) + cli_state_with_user.sdk.detectionlists.high_risk_employee.get_all.side_effect = ( + generator + ) + res = runner.invoke(cli, ["high-risk-employee", "list"], obj=cli_state_with_user) + assert "None" in res.output + + def test_add_high_risk_employee_adds(runner, cli_state_with_user): runner.invoke( cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user From 1be5908b08e9814d1d7b07697eb0c5c37c8d5792 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Tue, 29 Dec 2020 09:55:32 +0530 Subject: [PATCH 155/349] More integration tests (#173) * Added positive cases for audit-logs command * added alerts, alert_rules and audit-logs tests * more tests * removed incomplete tests * reformat style * String format * added integration marker * added test dependency --- integration/test_alert_rules.py | 57 +++++++++++++++++++++++ integration/test_alerts.py | 45 ++++++++++++++---- integration/test_auditlogs.py | 38 ++++++++++++++-- integration/test_departing_employee.py | 30 ++++++++++++ integration/test_high_risk_employee.py | 29 ++++++++++++ integration/test_legal_hold.py | 63 ++++++++++++++++++++++++++ run_integration.py | 13 ------ tox.ini | 7 ++- 8 files changed, 256 insertions(+), 26 deletions(-) create mode 100644 integration/test_alert_rules.py create mode 100644 integration/test_departing_employee.py create mode 100644 integration/test_high_risk_employee.py create mode 100644 integration/test_legal_hold.py delete mode 100644 run_integration.py diff --git a/integration/test_alert_rules.py b/integration/test_alert_rules.py new file mode 100644 index 000000000..b6846a739 --- /dev/null +++ b/integration/test_alert_rules.py @@ -0,0 +1,57 @@ +import pytest +from integration import run_command + +ALERT_RULES_COMMAND = "code42 alert-rules" + + +@pytest.mark.integration +@pytest.mark.parametrize( + "command", + [ + "{} list".format(ALERT_RULES_COMMAND), + "{} show test-rule-id".format(ALERT_RULES_COMMAND), + "{} list -f CSV".format(ALERT_RULES_COMMAND), + "{} list -f TABLE".format(ALERT_RULES_COMMAND), + "{} list -f RAW-JSON".format(ALERT_RULES_COMMAND), + "{} list -f JSON".format(ALERT_RULES_COMMAND), + "{} list --format CSV".format(ALERT_RULES_COMMAND), + "{} list --format TABLE".format(ALERT_RULES_COMMAND), + "{} list --format JSON".format(ALERT_RULES_COMMAND), + "{} list --format RAW-JSON".format(ALERT_RULES_COMMAND), + ], +) +def test_alert_rules_command_returns_success_return_code(command): + return_code, response = run_command(command) + assert return_code == 0 + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ( + "{} add-user --rule-id test-rule-id".format(ALERT_RULES_COMMAND), + "Missing option '-u' / '--username'.", + ), + ( + "{} remove-user --rule-id test-rule-id".format(ALERT_RULES_COMMAND), + "Missing option '-u' / '--username'.", + ), + ("{} add-user".format(ALERT_RULES_COMMAND), "Missing option '--rule-id'."), + ("{} remove-user".format(ALERT_RULES_COMMAND), "Missing option '--rule-id'."), + ("{} show".format(ALERT_RULES_COMMAND), "Missing argument 'RULE_ID'."), + ( + "{} bulk add".format(ALERT_RULES_COMMAND), + "Error: Missing argument 'CSV_FILE'.", + ), + ( + "{} bulk remove".format(ALERT_RULES_COMMAND), + "Error: Missing argument 'CSV_FILE'.", + ), + ], +) +def test_alert_rules_command_returns_error_exit_status_when_missing_required_parameters( + command, error_msg +): + return_code, response = run_command(command) + assert return_code == 2 + assert error_msg in "".join(response) diff --git a/integration/test_alerts.py b/integration/test_alerts.py index c559b1abe..57038f665 100644 --- a/integration/test_alerts.py +++ b/integration/test_alerts.py @@ -11,21 +11,48 @@ end_date_str = end_date.strftime("%Y-%m-%d") ALERT_COMMAND = "code42 alerts search -b {} -e {}".format(begin_date_str, end_date_str) +ADVANCED_QUERY = """{"groupClause":"AND", "groups":[{"filterClause":"AND", +"filters":[{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"2020-09-13T00:00:00.000Z"}, +{"operator":"ON_OR_BEFORE", "term":"eventTimestamp", "value":"2020-12-07T13:20:15.195Z"}]}], +"srtDir":"asc", "srtKey":"eventId", "pgNum":1, "pgSize":10000} +""" +ALERT_ADVANCED_QUERY_COMMAND = "code42 alerts search --advanced-query '{}'".format( + ADVANCED_QUERY +) +@pytest.mark.integration @pytest.mark.parametrize( "command", [ - ("{}".format(ALERT_COMMAND)), - ("{} --state OPEN".format(ALERT_COMMAND)), - ("{} --state RESOLVED".format(ALERT_COMMAND)), - ("{} --actor user@code42.com".format(ALERT_COMMAND)), - ("{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND)), - ("{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND)), - ("{} --rule-type FedEndpointExfiltration".format(ALERT_COMMAND)), - ("{} --description 'Alert on any file upload'".format(ALERT_COMMAND)), + ALERT_COMMAND, + "{} --state OPEN".format(ALERT_COMMAND), + "{} --state RESOLVED".format(ALERT_COMMAND), + "{} --actor user@code42.com".format(ALERT_COMMAND), + "{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), + "{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND), + "{} --rule-type FedEndpointExfiltration".format(ALERT_COMMAND), + "{} --description 'Alert on any file upload'".format(ALERT_COMMAND), + "{} --exclude-rule-type 'FedEndpointExfiltration'".format(ALERT_COMMAND), + "{} --exclude-rule-id '962a6a1c-54f6-4477-90bd-a08cc74cbf71'".format( + ALERT_COMMAND + ), + "{} --exclude-rule-name 'File Upload Alert'".format(ALERT_COMMAND), + "{} --exclude-actor-contains 'user@code42.com'".format(ALERT_COMMAND), + "{} --exclude-actor 'user@code42.com'".format(ALERT_COMMAND), + "{} --actor-contains 'user@code42.com'".format(ALERT_COMMAND), + ALERT_ADVANCED_QUERY_COMMAND, ], ) -def test_alert_returns_success_return_code(command): +def test_alert_command_returns_success_return_code(command): return_code, response = run_command(command) assert return_code == 0 + + +@pytest.mark.parametrize( + "command", ["{} --advanced-query '{}'".format(ALERT_COMMAND, ADVANCED_QUERY)] +) +def test_begin_cant_be_used_with_advanced_query(command): + return_code, response = run_command(command) + assert return_code == 2 + assert "--begin can't be used with: --advanced-query" in response[0] diff --git a/integration/test_auditlogs.py b/integration/test_auditlogs.py index 047f4c219..cdf4b4bbe 100644 --- a/integration/test_auditlogs.py +++ b/integration/test_auditlogs.py @@ -4,12 +4,44 @@ import pytest from integration import run_command -BASE_COMMAND = "code42 audit-logs search -b" +SEARCH_COMMAND = "code42 audit-logs search" +BASE_COMMAND = "{} -b".format(SEARCH_COMMAND) begin_date = datetime.utcnow() - timedelta(days=-10) begin_date_str = begin_date.strftime("%Y-%m-%d %H:%M:%S") +end_date = datetime.utcnow() - timedelta(days=10) +end_date_str = end_date.strftime("%Y-%m-%d %H:%M:%S") -@pytest.mark.parametrize("command", [("{} '{}'".format(BASE_COMMAND, begin_date_str))]) -def test_auditlogs_search(command): +@pytest.mark.integration +@pytest.mark.parametrize( + "command", + [ + ("{} '{}'".format(BASE_COMMAND, begin_date_str)), + ("{} '{}' -e '{}'".format(BASE_COMMAND, begin_date_str, end_date_str)), + ("{} '{}' --end '{}'".format(BASE_COMMAND, begin_date_str, end_date_str)), + ("{} '{}' --event-type '{}'".format(BASE_COMMAND, begin_date_str, "test")), + ("{} '{}' --username '{}'".format(BASE_COMMAND, begin_date_str, "test")), + ("{} '{}' --user-id '{}'".format(BASE_COMMAND, begin_date_str, "123")), + ("{} '{}' --user-ip '{}'".format(BASE_COMMAND, begin_date_str, "0.0.0.0")), + ("{} '{}' --affected-user-id '{}'".format(BASE_COMMAND, begin_date_str, "123")), + ( + "{} '{}' --affected-username '{}'".format( + BASE_COMMAND, begin_date_str, "test" + ) + ), + ("{} '{}' -f {}".format(BASE_COMMAND, begin_date_str, "CSV")), + ("{} '{}' -f '{}'".format(BASE_COMMAND, begin_date_str, "TABLE")), + ("{} '{}' -f '{}'".format(BASE_COMMAND, begin_date_str, "JSON")), + ("{} '{}' -f '{}'".format(BASE_COMMAND, begin_date_str, "RAW-JSON")), + ("{} '{}' --format {}".format(BASE_COMMAND, begin_date_str, "CSV")), + ("{} '{}' --format '{}'".format(BASE_COMMAND, begin_date_str, "TABLE")), + ("{} '{}' --format '{}'".format(BASE_COMMAND, begin_date_str, "JSON")), + ("{} '{}' --format '{}'".format(BASE_COMMAND, begin_date_str, "RAW-JSON")), + ("{} --begin '{}'".format(SEARCH_COMMAND, begin_date_str)), + ("{} '{}' -d".format(BASE_COMMAND, begin_date_str)), + ("{} '{}' --debug".format(BASE_COMMAND, begin_date_str)), + ], +) +def test_auditlogs_search_command_returns_success_return_code(command): return_code, response = run_command(command) assert return_code == 0 diff --git a/integration/test_departing_employee.py b/integration/test_departing_employee.py new file mode 100644 index 000000000..7510b2933 --- /dev/null +++ b/integration/test_departing_employee.py @@ -0,0 +1,30 @@ +import pytest +from integration import run_command + +DEPARTING_EMPLOYEE_COMMAND = "code42 departing-employee" + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ("{} add".format(DEPARTING_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), + ( + "{} remove".format(DEPARTING_EMPLOYEE_COMMAND), + "Missing argument 'USERNAME'.", + ), + ( + "{} bulk add".format(DEPARTING_EMPLOYEE_COMMAND), + "Missing argument 'CSV_FILE'.", + ), + ( + "{} bulk remove".format(DEPARTING_EMPLOYEE_COMMAND), + "Missing argument 'FILE'.", + ), + ], +) +def test_departing_employee_command_returns_error_exit_status_when_missing_required_parameters( + command, error_msg +): + return_code, response = run_command(command) + assert return_code == 2 + assert error_msg in "".join(response) diff --git a/integration/test_high_risk_employee.py b/integration/test_high_risk_employee.py new file mode 100644 index 000000000..ff7ca7699 --- /dev/null +++ b/integration/test_high_risk_employee.py @@ -0,0 +1,29 @@ +import pytest +from integration import run_command + +HR_EMPLOYEE_COMMAND = "code42 high-risk-employee" + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ("{} add".format(HR_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), + ("{} remove".format(HR_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), + ("{} bulk add".format(HR_EMPLOYEE_COMMAND), "Missing argument 'CSV_FILE'."), + ("{} bulk remove".format(HR_EMPLOYEE_COMMAND), "Missing argument 'FILE'."), + ( + "{} bulk add-risk-tags".format(HR_EMPLOYEE_COMMAND), + "Missing argument 'CSV_FILE'.", + ), + ( + "{} bulk remove-risk-tags".format(HR_EMPLOYEE_COMMAND), + "Missing argument 'CSV_FILE'.", + ), + ], +) +def test_hr_employee_command_returns_error_exit_status_when_missing_required_parameters( + command, error_msg +): + return_code, response = run_command(command) + assert return_code == 2 + assert error_msg in "".join(response) diff --git a/integration/test_legal_hold.py b/integration/test_legal_hold.py new file mode 100644 index 000000000..8d3f9238e --- /dev/null +++ b/integration/test_legal_hold.py @@ -0,0 +1,63 @@ +import pytest +from integration import run_command + +LEGAL_HOLD_COMMAND = "code42 legal-hold" + + +@pytest.mark.integration +@pytest.mark.parametrize( + "command", + [ + "{} list".format(LEGAL_HOLD_COMMAND), + "{} show 984140047896012577".format(LEGAL_HOLD_COMMAND), + "{} list -f CSV".format(LEGAL_HOLD_COMMAND), + "{} list -f TABLE".format(LEGAL_HOLD_COMMAND), + "{} list -f RAW-JSON".format(LEGAL_HOLD_COMMAND), + "{} list -f JSON".format(LEGAL_HOLD_COMMAND), + "{} list --format CSV".format(LEGAL_HOLD_COMMAND), + "{} list --format TABLE".format(LEGAL_HOLD_COMMAND), + "{} list --format JSON".format(LEGAL_HOLD_COMMAND), + "{} list --format RAW-JSON".format(LEGAL_HOLD_COMMAND), + ], +) +def test_alert_rules_command_returns_success_return_code(command): + return_code, response = run_command(command) + assert return_code == 0 + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ( + "{} add-user --matter-id test-matter-id".format(LEGAL_HOLD_COMMAND), + "Missing option '-u' / '--username'.", + ), + ( + "{} remove-user --matter-id test-matter-id".format(LEGAL_HOLD_COMMAND), + "Missing option '-u' / '--username'.", + ), + ( + "{} add-user".format(LEGAL_HOLD_COMMAND), + "Missing option '-m' / '--matter-id'.", + ), + ( + "{} remove-user".format(LEGAL_HOLD_COMMAND), + "Missing option '-m' / '--matter-id'.", + ), + ("{} show".format(LEGAL_HOLD_COMMAND), "Missing argument 'MATTER_ID'."), + ( + "{} bulk add".format(LEGAL_HOLD_COMMAND), + "Error: Missing argument 'CSV_FILE'.", + ), + ( + "{} bulk remove".format(LEGAL_HOLD_COMMAND), + "Error: Missing argument 'CSV_FILE'.", + ), + ], +) +def test_alert_rules_command_returns_error_exit_status_when_missing_required_parameters( + command, error_msg +): + return_code, response = run_command(command) + assert return_code == 2 + assert error_msg in "".join(response) diff --git a/run_integration.py b/run_integration.py deleted file mode 100644 index da21ee8a5..000000000 --- a/run_integration.py +++ /dev/null @@ -1,13 +0,0 @@ -import os -import sys - -if __name__ == "__main__": - if sys.argv[1] and sys.argv[2]: - os.environ["C42_USER"] = sys.argv[1] - os.environ["C42_PW"] = sys.argv[2] - rc = os.system("pytest ./integration -v -rsxX -l --tb=short --strict") - sys.exit(rc) - else: - print( - "username and password were not supplied. Integration tests will be skipped." - ) diff --git a/tox.ini b/tox.ini index 6031d7c65..6154ee5ea 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ deps = pytest == 4.6.11 pytest-mock == 2.0.0 pytest-cov == 2.10.0 + pexpect == 4.8.0 commands = # -v: verbose @@ -17,7 +18,7 @@ commands = # -l: show locals in tracebacks # --tb=short: short traceback print mode # --strict: marks not registered in configuration file raise errors - pytest --cov=code42cli --cov-report xml -v -rsxX -l --tb=short --strict + pytest --cov=code42cli --cov-report xml -v -rsxX -l --tb=short --strict -m "not integration" [testenv:docs] deps = @@ -44,3 +45,7 @@ deps = pytest-cov == 2.10.0 git+https://github.com/code42/py42.git@master#egg=py42 git+ssh://git@github.com/code42/c42eventextractor.git@master#egg=c42eventextractor + +[pytest] +markers = + integration: mark test as a integration test. From 1b4f2d9af80e91a6d205019ad844f2cadd32d92d Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 30 Dec 2020 09:26:27 -0600 Subject: [PATCH 156/349] fix nightly test runs (#193) * fix nightly test runs * remove dupe command * stop testing python3.5 * revert --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 6154ee5ea..0e16de15e 100644 --- a/tox.ini +++ b/tox.ini @@ -43,9 +43,11 @@ deps = pytest == 4.6.11 pytest-mock == 2.0.0 pytest-cov == 2.10.0 + pexpect == 4.8.0 git+https://github.com/code42/py42.git@master#egg=py42 git+ssh://git@github.com/code42/c42eventextractor.git@master#egg=c42eventextractor + [pytest] markers = integration: mark test as a integration test. From 3c0704f60e3bd1e964f219a3a82d11d3cc02b5ba Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 30 Dec 2020 13:55:14 -0600 Subject: [PATCH 157/349] Add devices command (#167) * add devices command and devices deactivate command * added get_info, cold storage and device name updating for deactivate, and some additional tests for deactivate * fixed date-sensitive test and added tests for get-info * update tox.ini, add more tests, add output to bulk_list * added drop_n_most_recent parameter * added more options to devices bulk info * added bulk deactivate command * added changelog * made include backup usage bool * added option to add username to devices bulk info output * added days-since-last-connected option, and updated file_readers.py to allow csvs which have extra columns as long as they have all the appropriate headers. This should be a non-breaking change, so CSVs which do not have a header row but do have the appropriate number of columns should continue to work * re-ordered bulk info function and added more verbose output to bulk deactivate * removed extraneous echo line * added results report output and updated run_bulk_process and worker to handle more specific result reporting, rather than just an error number * initial settings implementation * refactored bulk info --include-settings to use bulk processing * changed command names for devices show and devices bulk list for consistency * added tests to document changes to read_csv * removed unneeded import, style changes * added some tests and updated formatting * updated help text and setup.py * updated build.yml for removal of 3.5 * move list out of bulk group * simplified included/excluded file processing in devices list * updated devices show to more gracefully handle multiple backup sets * updated devices show and devices deactivate to use GUID instead of deviceId. devices bulk deactivate still requires the legacy deviceId value, so updated docstring to clarify * added formatting options for dataframe * style * update backup set output in show * updated option descriptions * update datetime format argument, device info maps, remove indexing from output * style * refactor device info keys maps * refactor --include-settings to have one line per backup set * added devices list-backup-sets * cleanup and remove extraneous call from --include-settings * fix failing test * fix --include-settings and setup.py * rename list method to avoid name conflict * ensure accurate timezone math and clarify loc call in _drop_devices_which_have_not_connected_in_some_number_of_days * fix timestamps * update tests --- .github/workflows/build.yml | 2 +- CHANGELOG.md | 8 + setup.py | 4 +- src/code42cli/bulk.py | 6 +- src/code42cli/cmds/devices.py | 448 ++++++++++++++++++++++++++ src/code42cli/file_readers.py | 33 +- src/code42cli/main.py | 2 + src/code42cli/output_formats.py | 25 ++ src/code42cli/worker.py | 17 +- tests/cmds/test_devices.py | 552 ++++++++++++++++++++++++++++++++ tests/conftest.py | 15 + tests/test_bulk.py | 11 + tests/test_file_readers.py | 32 ++ tests/test_output_formats.py | 49 +++ tox.ini | 3 +- 15 files changed, 1188 insertions(+), 19 deletions(-) create mode 100644 src/code42cli/cmds/devices.py create mode 100644 tests/cmds/test_devices.py create mode 100644 tests/test_file_readers.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8af91364a..104d59fa8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.5, 3.6, 3.7, 3.8] + python: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db5f3dd3..a6feeeef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta # Unreleased +- The `devices` command is added. Included are: + - `devices deactivate` to deactivate a single computer + - `devices show` to retrieve detailed information about a computer + - `devices list` to retrieve info about many devices, including device settings + - `devices bulk deactivate` to deactivate a list of devices + # Added - `code42 departing-employee list` command. @@ -34,6 +40,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `search` to search for audit-logs. - `send-to` to send audit-logs to server. +## Unreleased + ### Changed - `profile_name` argument is now required for `code42 profile delete`, as it was meant to be. diff --git a/setup.py b/setup.py index fc2c66c0d..ebe18873f 100644 --- a/setup.py +++ b/setup.py @@ -29,13 +29,14 @@ package_dir={"": "src"}, include_package_data=True, zip_safe=False, - python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4", + python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4", install_requires=[ "click>=7.1.1", "colorama>=0.4.3", "c42eventextractor==0.4.0", "keyring==18.0.1", "keyrings.alt==3.2.0", + "pandas>=1.1.3", "py42>=1.10", ], extras_require={ @@ -53,7 +54,6 @@ "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 38f695858..2f57b87c0 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -76,7 +76,7 @@ def run_bulk_process(row_handler, rows, progress_label=None): rows (iterable): the rows to process. """ processor = _create_bulk_processor(row_handler, rows, progress_label) - processor.run() + return processor.run() def _create_bulk_processor(row_handler, rows, progress_label): @@ -110,10 +110,12 @@ def __init__(self, row_handler, rows, worker=None, progress_label=None): def run(self): """Processes the csv rows specified in the ctor, calling `self.row_handler` on each row.""" + self._stats.reset_results() for row in self._rows: self._process_row(row) self.__worker.wait() self._print_results() + return self._stats._results def _process_row(self, row): if isinstance(row, dict): @@ -138,7 +140,7 @@ def _process_flat_file_row(self, row): ) def _handle_row(self, *args, **kwargs): - self._row_handler(*args, **kwargs) + return self._row_handler(*args, **kwargs) def _show_stats(self, _): return str(self._stats) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py new file mode 100644 index 000000000..00a6d6afc --- /dev/null +++ b/src/code42cli/cmds/devices.py @@ -0,0 +1,448 @@ +from datetime import date +from datetime import datetime + +import click +from pandas import concat +from pandas import DataFrame +from pandas import to_datetime +from pandas import to_timedelta +from py42 import exceptions +from py42.exceptions import Py42NotFoundError + +from code42cli.bulk import run_bulk_process +from code42cli.click_ext.groups import OrderedGroup +from code42cli.errors import Code42CLIError +from code42cli.file_readers import read_csv_arg +from code42cli.options import format_option +from code42cli.options import sdk_options +from code42cli.output_formats import DataFrameOutputFormatter +from code42cli.output_formats import OutputFormatter + + +@click.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def devices(state): + """For managing devices within your Code42 environment.""" + pass + + +device_guid_argument = click.argument("device-guid", type=str) + +change_device_name_option = click.option( + "--change-device-name", + required=False, + is_flag=True, + default=False, + help="""Prepend "deactivated_" and today's date to the name of any + deactivated devices.""", +) + +DATE_FORMAT = "%Y-%m-%d" +purge_date_option = click.option( + "--purge-date", + required=False, + type=click.DateTime(formats=[DATE_FORMAT]), + default=None, + help="""The date on which the archive should be purged from cold storage in yyyy-MM-dd format. + If not provided, the date will be set according to the appropriate org settings.""", +) + + +@devices.command() +@device_guid_argument +@change_device_name_option +@purge_date_option +@sdk_options() +def deactivate(state, device_guid, change_device_name, purge_date): + """Deactivate a device within Code42. Requires the device GUID to deactivate.""" + _deactivate_device(state.sdk, device_guid, change_device_name, purge_date) + + +def _deactivate_device(sdk, device_guid, change_device_name, purge_date): + device = sdk.devices.get_by_guid(device_guid) + try: + sdk.devices.deactivate(device.data["computerId"]) + except exceptions.Py42BadRequestError: + raise Code42CLIError("The device {} is in legal hold.".format(device_guid)) + except exceptions.Py42NotFoundError: + raise Code42CLIError("The device {} was not found.".format(device_guid)) + except exceptions.Py42ForbiddenError: + raise Code42CLIError("Unable to deactivate {}.".format(device_guid)) + if purge_date: + _update_cold_storage_purge_date(sdk, device_guid, purge_date) + if change_device_name and not device.data["name"].startswith("deactivated_"): + _change_device_name( + sdk, + device_guid, + "deactivated_" + + date.today().strftime("%Y-%m-%d") + + "_" + + device.data["name"], + ) + + +def _update_cold_storage_purge_date(sdk, guid, purge_date): + archives_response = sdk.archive.get_all_by_device_guid(guid) + archive_guid_list = [ + archive["archiveGuid"] + for page in archives_response + for archive in page["archives"] + if archive["format"] != "ARCHIVE_V2" + ] + for archive_guid in archive_guid_list: + sdk.archive.update_cold_storage_purge_date( + archive_guid, purge_date.strftime("%Y-%m-%d") + ) + + +def _change_device_name(sdk, guid, name): + device_settings = sdk.devices.get_settings(guid) + device_settings.name = name + sdk.devices.update_settings(device_settings) + + +@devices.command() +@device_guid_argument +@format_option +@sdk_options() +def show(state, device_guid, format=None): + """Print device info. Requires device GUID.""" + + formatter = OutputFormatter(format, _device_info_keys_map()) + backup_set_formatter = OutputFormatter(format, _backup_set_keys_map()) + device_info = _get_device_info(state.sdk, device_guid) + formatter.echo_formatted_list([device_info]) + click.echo() + backup_set_formatter.echo_formatted_list(device_info["backupUsage"]) + + +def _device_info_keys_map(): + return { + "name": "Name", + "osHostname": "Hostname", + "guid": "GUID", + "status": "Status", + "lastConnected": "Last Connected Date", + "productVersion": "Code42 Version", + "osName": "Operating System", + "osVersion": "Operating System Version", + } + + +def _backup_set_keys_map(): + return { + "targetComputerName": "Destination", + "lastBackup": "Last Backup Activity", + "lastCompleted": "Last Completed Backup", + "archiveBytes": "Archive Size in Bytes", + "archiveGuid": "Archive GUID", + } + + +def _get_device_info(sdk, device_guid): + return sdk.devices.get_by_guid(device_guid, include_backup_usage=True).data + + +active_option = click.option( + "--active", + required=False, + type=bool, + is_flag=True, + default=None, + help="Get only active or deactivated devices. Defaults to getting all devices.", +) + +org_uid_option = click.option( + "--org-uid", + required=False, + type=str, + default=None, + help="""Limit devices to only the ones in the org you specify. + Note that child orgs will be included.""", +) + +include_usernames_option = click.option( + "--include-usernames", + required=False, + type=bool, + default=False, + is_flag=True, + help="Add the username associated with a device to the output.", +) + + +@devices.command(name="list", help="Get information about many devices") +@active_option +@click.option( + "--days-since-last-connected", + required=False, + type=int, + help="Return only devices that have not connected in the number of days specified.", +) +@org_uid_option +@click.option( + "--drop-most-recent", + required=False, + type=int, + help="""Will drop the X most recently connected devices for each user from the + result list where X is the number you provide as this argument. Can be used to + avoid passing the most recently connected device for a user to the deactivate command.""", +) +@click.option( + "--include-backup-usage", + required=False, + type=bool, + default=False, + is_flag=True, + help="""Return backup usage information for each device + (may significantly lengthen the size of the return).""", +) +@include_usernames_option +@click.option( + "--include-settings", + required=False, + type=bool, + default=False, + is_flag=True, + help="""Include device settings in output.""", +) +@format_option +@sdk_options() +def list_devices( + state, + active, + days_since_last_connected, + drop_most_recent, + org_uid, + include_backup_usage, + include_usernames, + include_settings, + format, +): + """Outputs a list of all devices.""" + columns = [ + "computerId", + "guid", + "name", + "osHostname", + "status", + "lastConnected", + "productVersion", + "osName", + "osVersion", + "userUid", + ] + devices_dataframe = _get_device_dataframe( + state.sdk, columns, active, org_uid, include_backup_usage + ) + if drop_most_recent: + devices_dataframe = _drop_n_devices_per_user( + devices_dataframe, drop_most_recent + ) + if days_since_last_connected: + devices_dataframe = _drop_devices_which_have_not_connected_in_some_number_of_days( + devices_dataframe, days_since_last_connected + ) + if include_settings: + devices_dataframe = _add_settings_to_dataframe(state.sdk, devices_dataframe) + if include_usernames: + devices_dataframe = _add_usernames_to_device_dataframe( + state.sdk, devices_dataframe + ) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframe(devices_dataframe) + + +def _get_device_dataframe( + sdk, columns, active=None, org_uid=None, include_backup_usage=False +): + devices_generator = sdk.devices.get_all( + active=active, include_backup_usage=include_backup_usage, org_uid=org_uid + ) + devices_list = [] + if include_backup_usage: + columns.append("backupUsage") + for page in devices_generator: + devices_list.extend(page["computers"]) + return DataFrame.from_records(devices_list, columns=columns) + + +def _add_settings_to_dataframe(sdk, device_dataframe): + macos_guids = device_dataframe.loc[ + device_dataframe["osName"] == "mac", "guid" + ].values + + def handle_row(guid): + try: + full_disk_access_status = sdk.devices.get_agent_full_disk_access_state( + guid + ).data[ + "value" + ] # returns 404 error if device isn't a Mac or doesn't have full disk access + except Py42NotFoundError: + full_disk_access_status = False + return { + "guid": guid, + "full disk access status": full_disk_access_status, + } + + result_list = DataFrame.from_records( + run_bulk_process( + handle_row, macos_guids, progress_label="Getting device settings" + ) + ) + try: + return device_dataframe.merge(result_list, how="left", on="guid") + except KeyError: + return device_dataframe + + +def _drop_devices_which_have_not_connected_in_some_number_of_days( + devices_dataframe, days_since_last_connected +): + utc_now = to_datetime(datetime.utcnow(), utc=True) + devices_last_connected_dates = to_datetime( + devices_dataframe["lastConnected"], utc=True + ) + days_since_last_connected_delta = to_timedelta( + days_since_last_connected, unit="days" + ) + return devices_dataframe.loc[ + utc_now - devices_last_connected_dates > days_since_last_connected_delta, :, + ] + + +def _drop_n_devices_per_user( + device_dataframe, + number_to_drop, + sort_field="lastConnected", + sort_ascending=False, + group_field="userUid", +): + return ( + device_dataframe.sort_values(by=sort_field, ascending=sort_ascending) + .drop(device_dataframe.groupby(group_field).head(number_to_drop).index) + .reset_index(drop=True) + ) + + +def _add_usernames_to_device_dataframe(sdk, device_dataframe): + users_generator = sdk.users.get_all() + users_list = [] + for page in users_generator: + users_list.extend(page["users"]) + users_dataframe = DataFrame.from_records( + users_list, columns=["username", "userUid"] + ) + return device_dataframe.merge(users_dataframe, how="left", on="userUid") + + +@devices.command( + name="list-backup-sets", + help="Get information about many devices and their backup sets", +) +@active_option +@org_uid_option +@include_usernames_option +@format_option +@sdk_options() +def list_backup_sets( + state, active, org_uid, include_usernames, format, +): + """Outputs a list of all devices.""" + columns = ["guid", "userUid"] + devices_dataframe = _get_device_dataframe(state.sdk, columns, active, org_uid) + if include_usernames: + devices_dataframe = _add_usernames_to_device_dataframe( + state.sdk, devices_dataframe + ) + devices_dataframe = _add_backup_set_settings_to_dataframe( + state.sdk, devices_dataframe + ) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframe(devices_dataframe) + + +def _add_backup_set_settings_to_dataframe(sdk, devices_dataframe): + rows = [{"guid": guid} for guid in devices_dataframe["guid"].values] + + def handle_row(guid): + try: + current_device_settings = sdk.devices.get_settings(guid) + except Exception as e: + return DataFrame.from_records( + [ + { + "guid": guid, + "ERROR": "Unable to retrieve device settings for {}: {}".format( + guid, e + ), + } + ] + ) + current_result_dataframe = DataFrame.from_records( + [ + { + "guid": current_device_settings.guid, + "backup set name": backup_set["name"], + "destinations": [ + destination for destination in backup_set.destinations.values() + ], + "included files": backup_set.included_files, + "excluded files": backup_set.excluded_files, + "filename exclusions": backup_set.filename_exclusions, + "locked": backup_set.locked, + } + for backup_set in current_device_settings.backup_sets + ] + ) + return current_result_dataframe + + result_list = run_bulk_process( + handle_row, rows, progress_label="Getting device settings" + ) + try: + return devices_dataframe.merge(concat(result_list), how="left", on="guid") + except KeyError: + return devices_dataframe + + +@devices.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def bulk(state): + """Tools for managing devices in bulk.""" + pass + + +@bulk.command( + name="deactivate", + help="""Deactivate all devices on the given list. + Takes as input a CSV with a 'guid' column.""", +) +@read_csv_arg(headers=["guid"]) +@change_device_name_option +@purge_date_option +@format_option +@sdk_options() +def bulk_deactivate(state, csv_rows, change_device_name, purge_date, format): + sdk = state.sdk + csv_rows[0]["deactivated"] = False + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + for row in csv_rows: + row["change_device_name"] = change_device_name + row["purge_date"] = purge_date + + def handle_row(**row): + try: + _deactivate_device( + sdk, row["guid"], row["change_device_name"], row["purge_date"] + ) + row["deactivated"] = "True" + except Exception as e: + row["deactivated"] = "False: {}".format(e) + return row + + result_rows = run_bulk_process( + handle_row, csv_rows, progress_label="Deactivating devices:" + ) + formatter.echo_formatted_list(result_rows) diff --git a/src/code42cli/file_readers.py b/src/code42cli/file_readers.py index b4a48a58a..24bd2d2ee 100644 --- a/src/code42cli/file_readers.py +++ b/src/code42cli/file_readers.py @@ -16,23 +16,32 @@ def read_csv_arg(headers): ) -def read_csv(file, headers=None): +def read_csv(file, headers): """Helper to read a csv file object into dict rows, automatically removing header row if it exists, and errors if column count doesn't match header list length. """ - reader = csv.DictReader(file, fieldnames=headers) + reader = csv.DictReader(file) first_row = next(reader) - if None in first_row or None in first_row.values(): - raise click.BadParameter( - "Column count in {} doesn't match expected headers: {}".format( - file.name, headers - ) - ) - # skip first row if it's the header values - if tuple(first_row.keys()) == tuple(first_row.values()): - return list(reader) + if all(header in first_row for header in headers): + return [ + {key: value for (key, value) in row.items() if key in headers} + for row in [first_row, *list(reader)] + ] else: - return [first_row, *list(reader)] + file.seek(0) + reader = csv.DictReader(file, fieldnames=headers) + first_row = next(reader) + if None in first_row or None in first_row.values(): + raise click.BadParameter( + "Expected headers {} not found in {} and column count doesn't match expected size".format( + headers, file.name + ) + ) + # skip first row if it's the header values + if tuple(first_row.keys()) == tuple(first_row.values()): + return list(reader) + else: + return [first_row, *list(reader)] def read_flat_file(file): diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 02dc20e68..f09925eb9 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -12,6 +12,7 @@ from code42cli.cmds.alerts import alerts from code42cli.cmds.auditlogs import audit_logs from code42cli.cmds.departing_employee import departing_employee +from code42cli.cmds.devices import devices from code42cli.cmds.high_risk_employee import high_risk_employee from code42cli.cmds.legal_hold import legal_hold from code42cli.cmds.profile import profile @@ -61,4 +62,5 @@ def cli(state): cli.add_command(high_risk_employee) cli.add_command(legal_hold) cli.add_command(profile) +cli.add_command(devices) cli.add_command(audit_logs) diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 0ad9595d7..aeeebf5fb 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -3,6 +3,7 @@ import json import click +from pandas import DataFrame from code42cli.util import find_format_width from code42cli.util import format_to_table @@ -76,6 +77,30 @@ def _requires_list_output(self): return self.output_format in (OutputFormat.TABLE, OutputFormat.CSV) +class DataFrameOutputFormatter: + def __init__(self, output_format): + output_format = output_format.upper() if output_format else OutputFormat.TABLE + self.output_format = output_format + self._format_func = DataFrame.to_string + self._output_args = {"index": False} + + if output_format == OutputFormat.CSV: + self._format_func = DataFrame.to_csv + elif output_format == OutputFormat.RAW: + self._format_func = DataFrame.to_json + self._output_args.update({"orient": "records", "lines": False}) + elif output_format == OutputFormat.JSON: + self._format_func = DataFrame.to_json + self._output_args.update({"orient": "records", "lines": True}) + + def _format_output(self, output, *args, **kwargs): + self._output_args.update(kwargs) + return self._format_func(output, *args, **self._output_args) + + def echo_formatted_dataframe(self, output, *args, **kwargs): + click.echo_via_pager(self._format_output(output, *args, **kwargs)) + + def to_csv(output): """Output is a list of records""" diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index aa10420e7..3080837de 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -18,8 +18,10 @@ def __init__(self, total): _total_processed = 0 _total_errors = 0 + _results = [] __total_processed_lock = Lock() __total_errors_lock = Lock() + __results_lock = Lock() @property def total_processed(self): @@ -36,6 +38,10 @@ def total_successes(self): val = self._total_processed - self._total_errors return val if val >= 0 else 0 + @property + def results(self): + return self._results + def __str__(self): return "{} succeeded, {} failed out of {}".format( self.total_successes, self._total_errors, self.total @@ -51,6 +57,15 @@ def increment_total_errors(self): with self.__total_errors_lock: self._total_errors += 1 + def add_result(self, result): + """add a result to the list""" + with self.__results_lock: + self._results.append(result) + + def reset_results(self): + with self.__results_lock: + self._results = [] + class Worker: def __init__(self, thread_count, expected_total, bar=None): @@ -98,7 +113,7 @@ def _process_queue(self): func = task["func"] args = task["args"] kwargs = task["kwargs"] - func(*args, **kwargs) + self._stats.add_result(func(*args, **kwargs)) except Code42CLIError as err: self._increment_total_errors() self._logger.log_error(err) diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py new file mode 100644 index 000000000..94e7bc30e --- /dev/null +++ b/tests/cmds/test_devices.py @@ -0,0 +1,552 @@ +from datetime import date + +import pytest +from pandas import DataFrame +from pandas import testing +from py42.exceptions import Py42BadRequestError +from py42.exceptions import Py42ForbiddenError +from py42.exceptions import Py42NotFoundError +from py42.response import Py42Response +from requests import HTTPError +from requests import Response + +from code42cli import PRODUCT_NAME +from code42cli.cmds.devices import _add_backup_set_settings_to_dataframe +from code42cli.cmds.devices import _add_usernames_to_device_dataframe +from code42cli.cmds.devices import ( + _drop_devices_which_have_not_connected_in_some_number_of_days, +) +from code42cli.cmds.devices import _drop_n_devices_per_user +from code42cli.cmds.devices import _get_device_dataframe +from code42cli.main import cli + +_NAMESPACE = "{}.cmds.devices".format(PRODUCT_NAME) +TEST_DEVICE_GUID = "954143368874689941" +TEST_DEVICE_ID = 139527 +TEST_ARCHIVE_GUID = "954143426849296547" +TEST_PURGE_DATE = "2020-10-12" +TEST_ARCHIVES_RESPONSE = { + "archives": [ + { + "archiveGuid": "954143426849296547", + "userId": None, + "userUid": None, + "archiveBytes": 1745757673, + "targetGuid": "632540230984925185", + "lastCompletedBackup": "2020-10-12T20:17:52.084Z", + "isColdStorage": False, + "lastMaintained": "2020-10-10T19:31:05.811Z", + "maintenanceDuration": 455, + "compactBytesRemoved": 0, + "storePointId": 1000, + "selectedBytes": 1658317953, + "selectedFiles": 596, + "todoBytes": 0, + "format": "ARCHIVE_V1", + } + ] +} +TEST_DEVICE_RESPONSE = """{"data":{"computerId":139527,"name":"testname","osHostname": +"testhostname","guid":"954143368874689941","type":"COMPUTER","status":"Active","active":true, +"blocked":false,"alertState":0,"alertStates":["OK"],"userId":203988,"userUid":"938960273869958201", +"orgId":3099,"orgUid":"915323705751579872","computerExtRef":null,"notes":null,"parentComputerId": +null,"parentComputerGuid":null,"lastConnected":"2020-10-12T16:55:40.632Z","osName":"win", +"osVersion":"10.0.18362","osArch":"amd64","address":"172.16.208.140:4242","remoteAddress": +"72.50.201.186","javaVersion":"11.0.4","modelInfo":null,"timeZone":"America/Chicago", +"version":1525200006822,"productVersion":"8.2.2","buildVersion":26,"creationDate": +"2020-05-14T13:03:20.302Z","modificationDate":"2020-10-12T16:55:40.632Z","loginDate": +"2020-10-12T12:54:45.132Z","service":"CrashPlan"}}""" +TEST_BACKUPUSAGE_RESPONSE = """{"metadata":{"timestamp":"2020-10-13T12:51:28.410Z", +"params":{"incBackupUsage":"True","idType":"guid"}},"data":{"computerId":1767,"name": +"SNWINTEST1","osHostname":"UNKNOWN","guid":"843290890230648046","type":"COMPUTER", +"status":"Active","active":true,"blocked":false,"alertState":2,"alertStates": +["CriticalConnectionAlert"],"userId":1934,"userUid":"843290130258496632","orgId":1067, +"orgUid":"843284512172838008","computerExtRef":null,"notes":null,"parentComputerId":null, +"parentComputerGuid":null,"lastConnected":"2018-04-13T20:57:12.496Z","osName":"win", +"osVersion":"10.0","osArch":"amd64","address":"10.0.1.23:4242","remoteAddress":"73.53.78.104", +"javaVersion":"1.8.0_144","modelInfo":null,"timeZone":"America/Los_Angeles","version": +1512021600671,"productVersion":"6.7.1","buildVersion":4615,"creationDate":"2018-04-10T19:23:23.564Z", +"modificationDate":"2018-06-29T17:41:12.616Z","loginDate":"2018-04-13T20:17:32.213Z","service": +"CrashPlan","backupUsage":[{"targetComputerParentId":null,"targetComputerParentGuid":null, +"targetComputerGuid":"632540230984925185","targetComputerName":"Code42 Cloud USA West", +"targetComputerOsName":null,"targetComputerType":"SERVER","selectedFiles":0,"selectedBytes":0, +"todoFiles":0,"todoBytes":0,"archiveBytes":119501,"billableBytes":119501,"sendRateAverage":0, +"completionRateAverage":0,"lastBackup":null,"lastCompletedBackup":null,"lastConnected": +"2018-04-11T16:23:35.776Z","lastMaintenanceDate":"2020-10-08T21:23:12.533Z","lastCompactDate": +"2020-10-08T21:23:12.411Z","modificationDate":"2020-10-12T16:19:01.267Z","creationDate": +"2018-04-10T19:48:29.903Z","using":true,"alertState":16,"alertStates":["CriticalBackupAlert"], +"percentComplete":0.0,"storePointId":1001,"storePointName":"cif-sea-2","serverId":1003,"serverGuid": +"836476656572622471","serverName":"cif-sea","serverHostName":"https://cif-sea.crashplan.com", +"isProvider":false,"archiveGuid":"843293524842941560","archiveFormat":"ARCHIVE_V1","activity": +{"connected":false,"backingUp":false,"restoring":false,"timeRemainingInMs":0, +"remainingFiles":0,"remainingBytes":0}}]}}""" +TEST_EMPTY_BACKUPUSAGE_RESPONSE = """{"metadata":{"timestamp":"2020-10-13T12:51:28.410Z","params": +{"incBackupUsage":"True","idType":"guid"}},"data":{"computerId":1767,"name":"SNWINTEST1", +"osHostname":"UNKNOWN","guid":"843290890230648046","type":"COMPUTER","status":"Active", +"active":true,"blocked":false,"alertState":2,"alertStates":["CriticalConnectionAlert"], +"userId":1934,"userUid":"843290130258496632","orgId":1067,"orgUid":"843284512172838008", +"computerExtRef":null,"notes":null,"parentComputerId":null,"parentComputerGuid":null,"lastConnected": +"2018-04-13T20:57:12.496Z","osName":"win","osVersion":"10.0","osArch":"amd64","address": +"10.0.1.23:4242","remoteAddress":"73.53.78.104","javaVersion":"1.8.0_144","modelInfo":null, +"timeZone":"America/Los_Angeles","version":1512021600671,"productVersion":"6.7.1","buildVersion": +4615,"creationDate":"2018-04-10T19:23:23.564Z","modificationDate":"2018-06-29T17:41:12.616Z", +"loginDate":"2018-04-13T20:17:32.213Z","service":"CrashPlan","backupUsage":[]}}""" +TEST_COMPUTER_PAGE = { + "computers": [ + { + "computerId": 1207, + "name": "ubuntu", + "osHostname": "UNKNOWN", + "guid": "839648314463407622", + "type": "COMPUTER", + "status": "Active, Deauthorized", + "active": True, + "blocked": False, + "alertState": 2, + "alertStates": ["CriticalConnectionAlert"], + "userId": 1014, + "userUid": "836473273124890369", + "orgId": 1017, + "orgUid": "836473214639515393", + "computerExtRef": None, + "notes": None, + "parentComputerId": None, + "parentComputerGuid": None, + "lastConnected": "2018-03-16T17:06:50.774Z", + "osName": "linux", + "osVersion": "4.4.0-96-generic", + "osArch": "amd64", + "address": "172.16.132.193:4242", + "remoteAddress": "38.92.134.129", + "javaVersion": "1.8.0_144", + "modelInfo": None, + "timeZone": "America/Chicago", + "version": 1512021600671, + "productVersion": "6.7.1", + "buildVersion": 4589, + "creationDate": "2018-03-16T16:20:00.871Z", + "modificationDate": "2020-09-03T13:32:02.383Z", + "loginDate": "2018-03-16T16:52:18.900Z", + "service": "CrashPlan", + }, + { + "computerId": 1281, + "name": "TOM-PC", + "osHostname": "UNKNOWN", + "guid": "840099921260026634", + "type": "COMPUTER", + "status": "Deactivated", + "active": False, + "blocked": False, + "alertState": 0, + "alertStates": ["OK"], + "userId": 1320, + "userUid": "840103986007089121", + "orgId": 1034, + "orgUid": "840098081282695137", + "computerExtRef": None, + "notes": None, + "parentComputerId": None, + "parentComputerGuid": None, + "lastConnected": "2018-03-19T20:04:02.999Z", + "osName": "win", + "osVersion": "6.1", + "osArch": "amd64", + "address": "172.16.3.34:4242", + "remoteAddress": "38.92.134.129", + "javaVersion": "1.8.0_121", + "modelInfo": None, + "timeZone": "America/Chicago", + "version": 1508734800652, + "productVersion": "6.5.2", + "buildVersion": 32, + "creationDate": "2018-03-19T19:43:16.918Z", + "modificationDate": "2020-09-08T15:43:45.875Z", + "loginDate": "2018-03-19T20:03:45.360Z", + "service": "CrashPlan", + }, + ] +} +TEST_USERS_LIST_PAGE = { + "totalCount": 2, + "users": [ + { + "userId": 1320, + "userUid": "840103986007089121", + "status": "Active", + "username": "ttranda_deactivated@ttrantest.com", + "email": "ttranda@ttrantest.com", + "firstName": "Thomas", + "lastName": "Tran", + "quotaInBytes": -1, + "orgId": 1034, + "orgUid": "840098081282695137", + "orgName": "Okta SSO", + "userExtRef": None, + "notes": None, + "active": True, + "blocked": False, + "emailPromo": True, + "invited": False, + "orgType": "ENTERPRISE", + "usernameIsAnEmail": True, + "creationDate": "2018-03-19T19:43:16.742Z", + "modificationDate": "2018-10-26T20:22:05.726Z", + "passwordReset": False, + "localAuthenticationOnly": False, + "licenses": ["admin.securityTools"], + }, + { + "userId": 1014, + "userUid": "836473273124890369", + "status": "Active", + "username": "qatest@code42.com", + "email": "qatest@code42.com", + "firstName": "Chad", + "lastName": "Valentine", + "quotaInBytes": -1, + "orgId": 1017, + "orgUid": "836473214639515393", + "orgName": "Holy SaaS-a-roli", + "userExtRef": None, + "notes": None, + "active": True, + "blocked": False, + "emailPromo": True, + "invited": False, + "orgType": "ENTERPRISE", + "usernameIsAnEmail": True, + "creationDate": "2018-02-22T18:35:23.217Z", + "modificationDate": "2018-04-25T11:12:11.504Z", + "passwordReset": False, + "localAuthenticationOnly": False, + "licenses": ["admin.securityTools"], + }, + ], +} + + +def _create_py42_response(mocker, text): + response = mocker.MagicMock(spec=Response) + response.text = text + response._content_consumed = mocker.MagicMock() + response.status_code = 200 + return Py42Response(response) + + +@pytest.fixture +def mock_device_settings(mocker, mock_backup_set): + device_settings = mocker.MagicMock() + device_settings.name = "testname" + device_settings.guid = "1234" + device_settings.backup_sets = [mock_backup_set, mock_backup_set] + return device_settings + + +@pytest.fixture +def mock_backup_set(mocker): + backup_set = mocker.MagicMock() + backup_set["name"] = "test_name" + backup_set.destinations = {"destination_guid": "destination_name"} + backup_set.excluded_files = ["/excluded/path"] + backup_set.included_files = ["/included/path"] + backup_set.filename_exclusions = [".*\\.excluded_filetype"] + backup_set.locked = True + return backup_set + + +@pytest.fixture +def deactivate_response(mocker): + return _create_py42_response(mocker, "") + + +@pytest.fixture +def device_info_response(mocker): + return _create_py42_response(mocker, TEST_DEVICE_RESPONSE) + + +def archives_list_generator(): + yield TEST_ARCHIVES_RESPONSE + + +def devices_list_generator(): + yield TEST_COMPUTER_PAGE + + +def users_list_generator(): + yield TEST_USERS_LIST_PAGE + + +@pytest.fixture +def backupusage_response(mocker): + return _create_py42_response(mocker, TEST_BACKUPUSAGE_RESPONSE) + + +@pytest.fixture +def empty_backupusage_response(mocker): + return _create_py42_response(mocker, TEST_EMPTY_BACKUPUSAGE_RESPONSE) + + +@pytest.fixture +def device_info_success(cli_state, device_info_response): + cli_state.sdk.devices.get_by_id.return_value = device_info_response + + +@pytest.fixture +def get_device_by_guid_success(cli_state, device_info_response): + cli_state.sdk.devices.get_by_guid.return_value = device_info_response + + +@pytest.fixture +def archives_list_success(cli_state): + cli_state.sdk.archive.get_all_by_device_guid.return_value = ( + archives_list_generator() + ) + + +@pytest.fixture +def deactivate_device_success(cli_state, deactivate_response): + cli_state.sdk.devices.deactivate.return_value = deactivate_response + + +@pytest.fixture +def deactivate_device_not_found_failure(cli_state): + cli_state.sdk.devices.deactivate.side_effect = Py42NotFoundError(HTTPError()) + + +@pytest.fixture +def deactivate_device_in_legal_hold_failure(cli_state): + cli_state.sdk.devices.deactivate.side_effect = Py42BadRequestError(HTTPError()) + + +@pytest.fixture +def deactivate_device_not_allowed_failure(cli_state): + cli_state.sdk.devices.deactivate.side_effect = Py42ForbiddenError(HTTPError()) + + +@pytest.fixture +def backupusage_success(cli_state, backupusage_response): + cli_state.sdk.devices.get_by_guid.return_value = backupusage_response + + +@pytest.fixture +def empty_backupusage_success(cli_state, empty_backupusage_response): + cli_state.sdk.devices.get_by_guid.return_value = empty_backupusage_response + + +@pytest.fixture +def get_all_devices_success(cli_state): + cli_state.sdk.devices.get_all.return_value = devices_list_generator() + + +@pytest.fixture +def get_all_users_success(cli_state): + cli_state.sdk.users.get_all.return_value = users_list_generator() + + +def test_deactivate_deactivates_device( + runner, cli_state, deactivate_device_success, get_device_by_guid_success +): + runner.invoke(cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state) + cli_state.sdk.devices.deactivate.assert_called_once_with(TEST_DEVICE_ID) + + +def test_deactivate_when_given_flag_updates_purge_date( + runner, + cli_state, + deactivate_device_success, + get_device_by_guid_success, + device_info_success, + archives_list_success, +): + runner.invoke( + cli, + ["devices", "deactivate", TEST_DEVICE_GUID, "--purge-date", TEST_PURGE_DATE], + obj=cli_state, + ) + cli_state.sdk.archive.update_cold_storage_purge_date.assert_called_once_with( + TEST_ARCHIVE_GUID, TEST_PURGE_DATE + ) + + +def test_deactivate_when_given_flag_changes_device_name( + runner, + cli_state, + deactivate_device_success, + get_device_by_guid_success, + device_info_success, + mock_device_settings, +): + cli_state.sdk.devices.get_settings.return_value = mock_device_settings + runner.invoke( + cli, + ["devices", "deactivate", TEST_DEVICE_GUID, "--change-device-name"], + obj=cli_state, + ) + assert ( + mock_device_settings.name + == "deactivated_" + date.today().strftime("%Y-%m-%d") + "_testname" + ) + cli_state.sdk.devices.update_settings.assert_called_once_with(mock_device_settings) + + +def test_deactivate_does_not_change_device_name_when_not_given_flag( + runner, + cli_state, + deactivate_device_success, + device_info_success, + mock_device_settings, +): + cli_state.sdk.devices.get_settings.return_value = mock_device_settings + runner.invoke( + cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state, + ) + assert mock_device_settings.name == "testname" + cli_state.sdk.devices.update_settings.assert_not_called() + + +def test_deactivate_fails_if_device_does_not_exist( + runner, cli_state, deactivate_device_not_found_failure +): + result = runner.invoke( + cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state + ) + assert result.exit_code == 1 + assert "The device {} was not found.".format(TEST_DEVICE_GUID) in result.output + + +def test_deactivate_fails_if_device_is_on_legal_hold( + runner, cli_state, deactivate_device_in_legal_hold_failure +): + result = runner.invoke( + cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state + ) + assert result.exit_code == 1 + assert "The device {} is in legal hold.".format(TEST_DEVICE_GUID) in result.output + + +def test_deactivate_fails_if_device_deactivation_forbidden( + runner, cli_state, deactivate_device_not_allowed_failure +): + result = runner.invoke( + cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state + ) + assert result.exit_code == 1 + assert "Unable to deactivate {}.".format(TEST_DEVICE_GUID) in result.output + + +def test_show_prints_device_info(runner, cli_state, backupusage_success): + result = runner.invoke(cli, ["devices", "show", TEST_DEVICE_GUID], obj=cli_state) + assert "SNWINTEST1" in result.output + assert "843290890230648046" in result.output + assert "119501" in result.output + assert "2018-04-13T20:57:12.496Z" in result.output + assert "6.7.1" in result.output + + +def test_show_prints_backup_set_info(runner, cli_state, backupusage_success): + result = runner.invoke(cli, ["devices", "show", TEST_DEVICE_GUID], obj=cli_state) + assert "Code42 Cloud USA West" in result.output + assert "843293524842941560" in result.output + + +def test_get_device_dataframe_returns_correct_columns( + cli_state, get_all_devices_success +): + columns = [ + "computerId", + "guid", + "name", + "osHostname", + "status", + "lastConnected", + "productVersion", + "osName", + "osVersion", + "userUid", + ] + result = _get_device_dataframe(cli_state.sdk, columns) + assert "computerId" in result.columns + assert "guid" in result.columns + assert "name" in result.columns + assert "osHostname" in result.columns + assert "guid" in result.columns + assert "status" in result.columns + assert "lastConnected" in result.columns + assert "productVersion" in result.columns + assert "osName" in result.columns + assert "osVersion" in result.columns + assert "modelInfo" not in result.columns + assert "address" not in result.columns + assert "buildVersion" not in result.columns + + +def test_device_dataframe_return_includes_backupusage_when_flag_passed( + cli_state, get_all_devices_success +): + result = _get_device_dataframe(cli_state.sdk, columns=[], include_backup_usage=True) + assert "backupUsage" in result.columns + + +def test_drop_n_devices_per_user_drops_correct_devices(): + testdf = DataFrame.from_records( + [ + {"userUid": 0, "lastConnected": 0}, + {"userUid": 0, "lastConnected": 1}, + {"userUid": 1, "lastConnected": 0}, + ] + ) + expected_return = DataFrame.from_records([{"userUid": 0, "lastConnected": 1}]) + returndf = _drop_n_devices_per_user(testdf, 1) + testing.assert_frame_equal(expected_return, returndf) + + +def test_add_usernames_to_device_dataframe_adds_usernames_to_dataframe( + cli_state, get_all_users_success +): + testdf = DataFrame.from_records( + [{"userUid": "840103986007089121"}, {"userUid": "836473273124890369"}] + ) + result = _add_usernames_to_device_dataframe(cli_state.sdk, testdf) + assert "username" in result.columns + + +def test_drop_devices_which_have_not_connected_in_some_number_of_days_drops_appropriate_devices(): + testdf = DataFrame.from_records( + [ + {"lastConnected": "2019-01-09T17:09:26.432Z"}, + {"lastConnected": date.today().isoformat() + "T17:09:26.432Z"}, + ] + ) + result = _drop_devices_which_have_not_connected_in_some_number_of_days(testdf, 30) + assert "2019-01-09T17:09:26.432Z" in result.values + assert date.today().isoformat() + "T17:09:26.432Z" not in result.values + + +def test_add_backup_set_settings_to_dataframe_returns_one_line_per_backup_set( + cli_state, mock_device_settings +): + cli_state.sdk.devices.get_settings.return_value = mock_device_settings + testdf = DataFrame.from_records([{"guid": "1234"}]) + result = _add_backup_set_settings_to_dataframe(cli_state.sdk, testdf) + assert len(result) == 2 + + +def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + with runner.isolated_filesystem(): + with open("test_bulk_deactivate.csv", "w") as csv: + csv.writelines(["guid,username\n", "test,value\n"]) + runner.invoke( + cli, + ["devices", "bulk", "deactivate", "test_bulk_deactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + { + "guid": "test", + "deactivated": False, + "change_device_name": False, + "purge_date": None, + } + ] diff --git a/tests/conftest.py b/tests/conftest.py index 3e440c802..6d7c17c68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -265,3 +265,18 @@ def mock_to_json(mocker): @pytest.fixture def mock_to_formatted_json(mocker): return mocker.patch("code42cli.output_formats.to_formatted_json") + + +@pytest.fixture +def mock_dataframe_to_json(mocker): + return mocker.patch("pandas.DataFrame.to_json") + + +@pytest.fixture +def mock_dataframe_to_csv(mocker): + return mocker.patch("pandas.DataFrame.to_csv") + + +@pytest.fixture +def mock_dataframe_to_string(mocker): + return mocker.patch("pandas.DataFrame.to_string") diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 2dd4eb49d..90ef1539c 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -153,3 +153,14 @@ def func_for_bulk(test1, test2): processor.run() assert (None, "foo") in processed_rows assert ("bar", None) in processed_rows + + def test_processor_stores_results_in_stats(self,): + def func_for_bulk(test): + return test + + rows = ["row1", "row2", "row3"] + processor = BulkProcessor(func_for_bulk, rows) + processor.run() + assert "row1" in processor._stats.results + assert "row2" in processor._stats.results + assert "row3" in processor._stats.results diff --git a/tests/test_file_readers.py b/tests/test_file_readers.py new file mode 100644 index 000000000..3b08159e0 --- /dev/null +++ b/tests/test_file_readers.py @@ -0,0 +1,32 @@ +from code42cli.file_readers import read_csv + +HEADERLESS_CSV = [ + "col1_val1,col2_val1,col3_val1\n", + "col1_val2,col2_val2,col3_val2\n", +] +HEADERS = ["header1", "header2", "header3"] +HEADERED_CSV = [ + "header2,header1,header3,extra_column\n" + "col2_val1,col1_val1,col3_val1,extra_value\n", + "col2_val2,col1_val2,col3_val2,extra_value\n", +] + + +def test_read_csv_handles_headerless_columns_in_proper_number_and_order(runner): + with runner.isolated_filesystem(): + with open("test_csv.csv", "w") as csv: + csv.writelines(HEADERLESS_CSV) + with open("test_csv.csv") as csv: + result_list = read_csv(file=csv, headers=HEADERS) + assert result_list[0]["header1"] == "col1_val1" + assert result_list[1]["header3"] == "col3_val2" + + +def test_read_scv_handles_headered_columns_in_arbitrary_number_and_order(runner): + with runner.isolated_filesystem(): + with open("test_csv.csv", "w") as csv: + csv.writelines(HEADERED_CSV) + with open("test_csv.csv") as csv: + result_list = read_csv(file=csv, headers=HEADERS) + assert result_list[0]["header1"] == "col1_val1" + assert result_list[1]["header3"] == "col3_val2" diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index 6609149b4..9c824f750 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -1,6 +1,8 @@ import json from collections import OrderedDict +from pandas import DataFrame + import code42cli.output_formats as output_formats_module @@ -57,6 +59,8 @@ "createdAt": "2020-05-18T11:47:16.6109560Z", }, ] +TEST_DATAFRAME = DataFrame.from_records(TEST_DATA) + TEST_HEADER = OrderedDict() TEST_HEADER["observerRuleId"] = "RuleId" @@ -188,3 +192,48 @@ def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed for _ in formatter.get_formatted_output("TEST"): pass mock_to_table.assert_called_once_with("TEST", None) + + +class TestDataFrameOutputFormatter: + def test_init_sets_format_func_to_formatted_json_function_when_json_format_option_is_passed( + self, mock_dataframe_to_json + ): + output_format = output_formats_module.OutputFormat.RAW + formatter = output_formats_module.DataFrameOutputFormatter(output_format) + formatter.echo_formatted_dataframe(TEST_DATAFRAME) + mock_dataframe_to_json.assert_called_once_with( + TEST_DATAFRAME, orient="records", lines=False, index=False + ) + + def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_passed( + self, mock_dataframe_to_json + ): + output_format = output_formats_module.OutputFormat.JSON + formatter = output_formats_module.DataFrameOutputFormatter(output_format) + formatter.echo_formatted_dataframe(TEST_DATAFRAME) + mock_dataframe_to_json.assert_called_once_with( + TEST_DATAFRAME, orient="records", lines=True, index=False + ) + + def test_init_sets_format_func_to_table_function_when_table_format_option_is_passed( + self, mock_dataframe_to_string + ): + output_format = output_formats_module.OutputFormat.TABLE + formatter = output_formats_module.DataFrameOutputFormatter(output_format) + formatter.echo_formatted_dataframe(TEST_DATAFRAME) + mock_dataframe_to_string.assert_called_once_with(TEST_DATAFRAME, index=False) + + def test_init_sets_format_func_to_csv_function_when_csv_format_option_is_passed( + self, mock_dataframe_to_csv + ): + output_format = output_formats_module.OutputFormat.CSV + formatter = output_formats_module.DataFrameOutputFormatter(output_format) + formatter.echo_formatted_dataframe(TEST_DATAFRAME) + mock_dataframe_to_csv.assert_called_once_with(TEST_DATAFRAME, index=False) + + def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed( + self, mock_dataframe_to_string + ): + formatter = output_formats_module.DataFrameOutputFormatter(None) + formatter.echo_formatted_dataframe(TEST_DATAFRAME) + mock_dataframe_to_string.assert_called_once_with(TEST_DATAFRAME, index=False) diff --git a/tox.ini b/tox.ini index 0e16de15e..efd358a76 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,37,36,35} + py{38,37,36} docs style skip_missing_interpreters = true @@ -10,6 +10,7 @@ deps = pytest == 4.6.11 pytest-mock == 2.0.0 pytest-cov == 2.10.0 + pandas == 1.1.3 pexpect == 4.8.0 commands = From f899f36db04c36d050aed5ec8f0c6a5ec1f56856 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 4 Jan 2021 08:37:01 -0600 Subject: [PATCH 158/349] remove 3.5 from nightly, and remove references from docs (#194) --- .github/workflows/nightly.yml | 2 +- CONTRIBUTING.md | 6 +++--- README.md | 2 +- docs/index.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 165094a94..cedec8aa6 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.5, 3.6, 3.7, 3.8] + python: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c1648b03..ae9fb1837 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ eval "$(pyenv init -)" eval "$(pyenv virtualenv-init -)" ``` -Then, create your virtual environment. While code42cli runs on python 3.5+, a 3.6+ version is required for development in order to run all of the unit tests and style checks. +Then, create your virtual environment. ```bash pyenv install 3.6.10 @@ -84,7 +84,7 @@ point to your virtual environment, and you should be ready to go! ## Run a full build -We use [tox](https://tox.readthedocs.io/en/latest/#) to run our build against Python 3.5, 3.6, 3.7, and 3.8. When run locally, `tox` will run only against the version of python that your virtual envrionment is running, but all versions will be validated against when you [open a PR](#opening-a-pr). +We use [tox](https://tox.readthedocs.io/en/latest/#) to run our build against Python 3.6, 3.7, and 3.8. When run locally, `tox` will run only against the version of python that your virtual envrionment is running, but all versions will be validated against when you [open a PR](#opening-a-pr). To run all the unit tests, do a test build of the documentation, and check that the code meets all style requirements, simply run: @@ -95,7 +95,7 @@ If the full process runs without any errors, your environment is set up correctl ## Coding Style -Use syntax and built-in modules that are compatible with Python 3.5+. +Use syntax and built-in modules that are compatible with Python 3.6+. ### Style linter diff --git a/README.md b/README.md index 833e6a8e2..9a84d64c5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Use the `code42` command to interact with your Code42 environment. ## Requirements -- Python 3.5.0+ +- Python 3.6.0+ - Code42 Server 6.8.x+ ## Installation diff --git a/docs/index.md b/docs/index.md index 761969c7f..aab85d45d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ To use the Code42 CLI, you must have: * A [Code42 product plan](https://code42.com/r/support/product-plans) that supports the feature or functionality for your use case * Endpoint monitoring enabled in the Code42 console -* Python version 3.5 and later installed +* Python version 3.6 and later installed ## Content From 5f2b52bf41d4319be065a85bffeb4bca9d911703 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 5 Jan 2021 10:25:00 -0600 Subject: [PATCH 159/349] Change names of variables in util method (#195) --- src/code42cli/util.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 7fb1919f3..cc392bbd3 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -59,18 +59,16 @@ def find_format_width(record, header, include_header=True): if not header: header = _get_default_header(record) rows.append(header) - max_width_item = dict(header.items()) # Copy + widths = dict(header.items()) # Copy for record_row in record: row = OrderedDict() for header_key in header.keys(): item = record_row.get(header_key) row[header_key] = item - max_width_item[header_key] = max( - max_width_item[header_key], str(item), key=len - ) + widths[header_key] = max(widths[header_key], str(item), key=len) rows.append(row) - column_size = {key: len(value) for key, value in max_width_item.items()} - return rows, column_size + column_sizes = {key: len(value) for key, value in widths.items()} + return rows, column_sizes def format_to_table(rows, column_size): From 18ec17d8e3c98a86021b75da6c17b7d37ade34d3 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 6 Jan 2021 10:46:43 -0600 Subject: [PATCH 160/349] Faster test run (#196) --- CONTRIBUTING.md | 6 ++++++ tox.ini | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae9fb1837..2f4502e56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,6 +124,12 @@ This will also test that the documentation build passes and run the style checks $ tox -e py ``` +If you want to run the integration tests in your current python environment, you can do: + +```bash +pytest -m "integration" +``` + ### Writing tests Put actual before expected values in assert statements. Pytest assumes this order. diff --git a/tox.ini b/tox.ini index efd358a76..68d680da2 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ commands = # -l: show locals in tracebacks # --tb=short: short traceback print mode # --strict: marks not registered in configuration file raise errors - pytest --cov=code42cli --cov-report xml -v -rsxX -l --tb=short --strict -m "not integration" + pytest --cov=code42cli --cov-report xml -v -rsxX -l --tb=short --strict --ignore=integration [testenv:docs] deps = From 0b4d7d3487e359d5e9229b8ab88713ee01fcb3e6 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 7 Jan 2021 08:59:51 -0600 Subject: [PATCH 161/349] move added feature to Added section (#197) --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6feeeef7..adb01df76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,14 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta # Unreleased -- The `devices` command is added. Included are: - - `devices deactivate` to deactivate a single computer - - `devices show` to retrieve detailed information about a computer - - `devices list` to retrieve info about many devices, including device settings - - `devices bulk deactivate` to deactivate a list of devices - # Added +- The `devices` command is added. Included are: + - `devices deactivate` to deactivate a single computer. + - `devices show` to retrieve detailed information about a computer. + - `devices list` to retrieve info about many devices, including device settings. + - `devices bulk deactivate` to deactivate a list of devices. + - `code42 departing-employee list` command. - `code42 high-risk-employee list` command. From 1511898f3c6721ed3ca4b3b5e262c38e529aa1f7 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 13 Jan 2021 08:37:59 -0600 Subject: [PATCH 162/349] fix help (#200) --- src/code42cli/cmds/auditlogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index db7a6c690..c22c4a6da 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -101,7 +101,7 @@ def filter_options(f): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def audit_logs(state): - """Retrieve audit logs.""" + """Tools for getting audit-log data.""" # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_audit_log_cursor_store From b213d112e1eda1e384c422aab8700dc23937494d Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 13 Jan 2021 21:19:25 +0530 Subject: [PATCH 163/349] Organize tests (#201) * move required args unit tests to unit test directory * rename test method names to proper convention --- integration/test_alert_rules.py | 32 --------------------- integration/test_alerts.py | 9 ------ integration/test_departing_employee.py | 30 -------------------- integration/test_high_risk_employee.py | 29 ------------------- integration/test_legal_hold.py | 38 ------------------------- tests/cmds/test_alert_rules.py | 34 ++++++++++++++++++++++ tests/cmds/test_departing_employee.py | 27 ++++++++++++++++++ tests/cmds/test_high_risk_employee.py | 26 +++++++++++++++++ tests/cmds/test_legal_hold.py | 39 ++++++++++++++++++++++++++ 9 files changed, 126 insertions(+), 138 deletions(-) delete mode 100644 integration/test_departing_employee.py delete mode 100644 integration/test_high_risk_employee.py diff --git a/integration/test_alert_rules.py b/integration/test_alert_rules.py index b6846a739..1091aeda2 100644 --- a/integration/test_alert_rules.py +++ b/integration/test_alert_rules.py @@ -23,35 +23,3 @@ def test_alert_rules_command_returns_success_return_code(command): return_code, response = run_command(command) assert return_code == 0 - - -@pytest.mark.parametrize( - "command, error_msg", - [ - ( - "{} add-user --rule-id test-rule-id".format(ALERT_RULES_COMMAND), - "Missing option '-u' / '--username'.", - ), - ( - "{} remove-user --rule-id test-rule-id".format(ALERT_RULES_COMMAND), - "Missing option '-u' / '--username'.", - ), - ("{} add-user".format(ALERT_RULES_COMMAND), "Missing option '--rule-id'."), - ("{} remove-user".format(ALERT_RULES_COMMAND), "Missing option '--rule-id'."), - ("{} show".format(ALERT_RULES_COMMAND), "Missing argument 'RULE_ID'."), - ( - "{} bulk add".format(ALERT_RULES_COMMAND), - "Error: Missing argument 'CSV_FILE'.", - ), - ( - "{} bulk remove".format(ALERT_RULES_COMMAND), - "Error: Missing argument 'CSV_FILE'.", - ), - ], -) -def test_alert_rules_command_returns_error_exit_status_when_missing_required_parameters( - command, error_msg -): - return_code, response = run_command(command) - assert return_code == 2 - assert error_msg in "".join(response) diff --git a/integration/test_alerts.py b/integration/test_alerts.py index 57038f665..da13d625b 100644 --- a/integration/test_alerts.py +++ b/integration/test_alerts.py @@ -47,12 +47,3 @@ def test_alert_command_returns_success_return_code(command): return_code, response = run_command(command) assert return_code == 0 - - -@pytest.mark.parametrize( - "command", ["{} --advanced-query '{}'".format(ALERT_COMMAND, ADVANCED_QUERY)] -) -def test_begin_cant_be_used_with_advanced_query(command): - return_code, response = run_command(command) - assert return_code == 2 - assert "--begin can't be used with: --advanced-query" in response[0] diff --git a/integration/test_departing_employee.py b/integration/test_departing_employee.py deleted file mode 100644 index 7510b2933..000000000 --- a/integration/test_departing_employee.py +++ /dev/null @@ -1,30 +0,0 @@ -import pytest -from integration import run_command - -DEPARTING_EMPLOYEE_COMMAND = "code42 departing-employee" - - -@pytest.mark.parametrize( - "command, error_msg", - [ - ("{} add".format(DEPARTING_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), - ( - "{} remove".format(DEPARTING_EMPLOYEE_COMMAND), - "Missing argument 'USERNAME'.", - ), - ( - "{} bulk add".format(DEPARTING_EMPLOYEE_COMMAND), - "Missing argument 'CSV_FILE'.", - ), - ( - "{} bulk remove".format(DEPARTING_EMPLOYEE_COMMAND), - "Missing argument 'FILE'.", - ), - ], -) -def test_departing_employee_command_returns_error_exit_status_when_missing_required_parameters( - command, error_msg -): - return_code, response = run_command(command) - assert return_code == 2 - assert error_msg in "".join(response) diff --git a/integration/test_high_risk_employee.py b/integration/test_high_risk_employee.py deleted file mode 100644 index ff7ca7699..000000000 --- a/integration/test_high_risk_employee.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest -from integration import run_command - -HR_EMPLOYEE_COMMAND = "code42 high-risk-employee" - - -@pytest.mark.parametrize( - "command, error_msg", - [ - ("{} add".format(HR_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), - ("{} remove".format(HR_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), - ("{} bulk add".format(HR_EMPLOYEE_COMMAND), "Missing argument 'CSV_FILE'."), - ("{} bulk remove".format(HR_EMPLOYEE_COMMAND), "Missing argument 'FILE'."), - ( - "{} bulk add-risk-tags".format(HR_EMPLOYEE_COMMAND), - "Missing argument 'CSV_FILE'.", - ), - ( - "{} bulk remove-risk-tags".format(HR_EMPLOYEE_COMMAND), - "Missing argument 'CSV_FILE'.", - ), - ], -) -def test_hr_employee_command_returns_error_exit_status_when_missing_required_parameters( - command, error_msg -): - return_code, response = run_command(command) - assert return_code == 2 - assert error_msg in "".join(response) diff --git a/integration/test_legal_hold.py b/integration/test_legal_hold.py index 8d3f9238e..674be1600 100644 --- a/integration/test_legal_hold.py +++ b/integration/test_legal_hold.py @@ -23,41 +23,3 @@ def test_alert_rules_command_returns_success_return_code(command): return_code, response = run_command(command) assert return_code == 0 - - -@pytest.mark.parametrize( - "command, error_msg", - [ - ( - "{} add-user --matter-id test-matter-id".format(LEGAL_HOLD_COMMAND), - "Missing option '-u' / '--username'.", - ), - ( - "{} remove-user --matter-id test-matter-id".format(LEGAL_HOLD_COMMAND), - "Missing option '-u' / '--username'.", - ), - ( - "{} add-user".format(LEGAL_HOLD_COMMAND), - "Missing option '-m' / '--matter-id'.", - ), - ( - "{} remove-user".format(LEGAL_HOLD_COMMAND), - "Missing option '-m' / '--matter-id'.", - ), - ("{} show".format(LEGAL_HOLD_COMMAND), "Missing argument 'MATTER_ID'."), - ( - "{} bulk add".format(LEGAL_HOLD_COMMAND), - "Error: Missing argument 'CSV_FILE'.", - ), - ( - "{} bulk remove".format(LEGAL_HOLD_COMMAND), - "Error: Missing argument 'CSV_FILE'.", - ), - ], -) -def test_alert_rules_command_returns_error_exit_status_when_missing_required_parameters( - command, error_msg -): - return_code, response = run_command(command) - assert return_code == 2 - assert error_msg in "".join(response) diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index cfb0ad67e..bb8eb5b2a 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -19,6 +19,8 @@ TEST_EMPTY_RULE_RESPONSE = {"ruleMetadata": []} +ALERT_RULES_COMMAND = "alert-rules" + TEST_RULE_RESPONSE = { "ruleMetadata": [ { @@ -343,3 +345,35 @@ def test_remove_when_user_not_on_rule_raises_expected_error(runner, cli_state, m ) in result.output ) + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ( + "{} add-user --rule-id test-rule-id".format(ALERT_RULES_COMMAND), + "Missing option '-u' / '--username'.", + ), + ( + "{} remove-user --rule-id test-rule-id".format(ALERT_RULES_COMMAND), + "Missing option '-u' / '--username'.", + ), + ("{} add-user".format(ALERT_RULES_COMMAND), "Missing option '--rule-id'."), + ("{} remove-user".format(ALERT_RULES_COMMAND), "Missing option '--rule-id'."), + ("{} show".format(ALERT_RULES_COMMAND), "Missing argument 'RULE_ID'."), + ( + "{} bulk add".format(ALERT_RULES_COMMAND), + "Error: Missing argument 'CSV_FILE'.", + ), + ( + "{} bulk remove".format(ALERT_RULES_COMMAND), + "Error: Missing argument 'CSV_FILE'.", + ), + ], +) +def test_alert_rules_command_when_missing_required_parameters_errors( + command, error_msg, runner, cli_state +): + result = runner.invoke(cli, command.split(" "), obj=cli_state) + assert result.exit_code == 2 + assert error_msg in "".join(result.output) diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index f70108891..75da9c6e2 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -24,6 +24,7 @@ "departureDate": "2020-07-07" } """ +DEPARTING_EMPLOYEE_COMMAND = "departing-employee" @pytest.fixture() @@ -337,3 +338,29 @@ def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( ) in result.output ) + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ("{} add".format(DEPARTING_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), + ( + "{} remove".format(DEPARTING_EMPLOYEE_COMMAND), + "Missing argument 'USERNAME'.", + ), + ( + "{} bulk add".format(DEPARTING_EMPLOYEE_COMMAND), + "Missing argument 'CSV_FILE'.", + ), + ( + "{} bulk remove".format(DEPARTING_EMPLOYEE_COMMAND), + "Missing argument 'FILE'.", + ), + ], +) +def test_departing_employee_command_when_missing_required_parameters_returns_error( + command, error_msg, cli_state, runner +): + result = runner.invoke(cli, command.split(" "), obj=cli_state) + assert result.exit_code == 2 + assert error_msg in "".join(result.output) diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 8818185cf..2bec47bcf 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -26,6 +26,7 @@ "riskFactors": ["PERFORMANCE_CONCERNS"] } """ +HR_EMPLOYEE_COMMAND = "high-risk-employee" @pytest.fixture() @@ -351,3 +352,28 @@ def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( ) in result.output ) + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ("{} add".format(HR_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), + ("{} remove".format(HR_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), + ("{} bulk add".format(HR_EMPLOYEE_COMMAND), "Missing argument 'CSV_FILE'."), + ("{} bulk remove".format(HR_EMPLOYEE_COMMAND), "Missing argument 'FILE'."), + ( + "{} bulk add-risk-tags".format(HR_EMPLOYEE_COMMAND), + "Missing argument 'CSV_FILE'.", + ), + ( + "{} bulk remove-risk-tags".format(HR_EMPLOYEE_COMMAND), + "Missing argument 'CSV_FILE'.", + ), + ], +) +def test_hr_employee_command_when_missing_required_parameters_returns_error( + command, error_msg, runner, cli_state +): + result = runner.invoke(cli, command.split(" "), obj=cli_state) + assert result.exit_code == 2 + assert error_msg in "".join(result.output) diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index e8c8655bc..6b4804370 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -171,6 +171,7 @@ """ EMPTY_MATTERS_RESPONSE = """{"legalHolds": []}""" ALL_MATTERS_RESPONSE = """{{"legalHolds": [{}]}}""".format(MATTER_RESPONSE) +LEGAL_HOLD_COMMAND = "legal-hold" def _create_py42_response(mocker, text): @@ -572,3 +573,41 @@ def test_list_with_csv_format_returns_no_response_when_response_is_empty( cli_state.sdk.legalhold.get_all_matters.return_value = empty_matters_response result = runner.invoke(cli, ["legal-hold", "list", "-f", "csv"], obj=cli_state) assert "Matter ID,Name,Description,Creator,Creation Date" not in result.output + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ( + "{} add-user --matter-id test-matter-id".format(LEGAL_HOLD_COMMAND), + "Missing option '-u' / '--username'.", + ), + ( + "{} remove-user --matter-id test-matter-id".format(LEGAL_HOLD_COMMAND), + "Missing option '-u' / '--username'.", + ), + ( + "{} add-user".format(LEGAL_HOLD_COMMAND), + "Missing option '-m' / '--matter-id'.", + ), + ( + "{} remove-user".format(LEGAL_HOLD_COMMAND), + "Missing option '-m' / '--matter-id'.", + ), + ("{} show".format(LEGAL_HOLD_COMMAND), "Missing argument 'MATTER_ID'."), + ( + "{} bulk add".format(LEGAL_HOLD_COMMAND), + "Error: Missing argument 'CSV_FILE'.", + ), + ( + "{} bulk remove".format(LEGAL_HOLD_COMMAND), + "Error: Missing argument 'CSV_FILE'.", + ), + ], +) +def test_alert_rules_command_when_missing_required_parameters_returns_error( + command, error_msg, runner, cli_state +): + result = runner.invoke(cli, command.split(" "), obj=cli_state) + assert result.exit_code == 2 + assert error_msg in "".join(result.output) From 2bb26ef169a72d533855c875498c445d44f271a4 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Fri, 15 Jan 2021 11:15:45 +0530 Subject: [PATCH 164/349] Refactor move all integration tests into tests directory (#203) * moved integration directory into tests directory * added comment --- {integration => tests/integration}/__init__.py | 0 {integration => tests/integration}/test_alert_rules.py | 2 +- {integration => tests/integration}/test_alerts.py | 2 +- {integration => tests/integration}/test_auditlogs.py | 2 +- {integration => tests/integration}/test_legal_hold.py | 2 +- {integration => tests/integration}/util.py | 0 tox.ini | 3 ++- 7 files changed, 6 insertions(+), 5 deletions(-) rename {integration => tests/integration}/__init__.py (100%) rename {integration => tests/integration}/test_alert_rules.py (95%) rename {integration => tests/integration}/test_alerts.py (97%) rename {integration => tests/integration}/test_auditlogs.py (98%) rename {integration => tests/integration}/test_legal_hold.py (95%) rename {integration => tests/integration}/util.py (100%) diff --git a/integration/__init__.py b/tests/integration/__init__.py similarity index 100% rename from integration/__init__.py rename to tests/integration/__init__.py diff --git a/integration/test_alert_rules.py b/tests/integration/test_alert_rules.py similarity index 95% rename from integration/test_alert_rules.py rename to tests/integration/test_alert_rules.py index 1091aeda2..3e38b784a 100644 --- a/integration/test_alert_rules.py +++ b/tests/integration/test_alert_rules.py @@ -1,5 +1,5 @@ import pytest -from integration import run_command +from tests.integration import run_command ALERT_RULES_COMMAND = "code42 alert-rules" diff --git a/integration/test_alerts.py b/tests/integration/test_alerts.py similarity index 97% rename from integration/test_alerts.py rename to tests/integration/test_alerts.py index da13d625b..59ec3d1aa 100644 --- a/integration/test_alerts.py +++ b/tests/integration/test_alerts.py @@ -2,7 +2,7 @@ from datetime import timedelta import pytest -from integration import run_command +from tests.integration import run_command begin_date = datetime.utcnow() - timedelta(days=20) diff --git a/integration/test_auditlogs.py b/tests/integration/test_auditlogs.py similarity index 98% rename from integration/test_auditlogs.py rename to tests/integration/test_auditlogs.py index cdf4b4bbe..382784f9c 100644 --- a/integration/test_auditlogs.py +++ b/tests/integration/test_auditlogs.py @@ -2,7 +2,7 @@ from datetime import timedelta import pytest -from integration import run_command +from tests.integration import run_command SEARCH_COMMAND = "code42 audit-logs search" BASE_COMMAND = "{} -b".format(SEARCH_COMMAND) diff --git a/integration/test_legal_hold.py b/tests/integration/test_legal_hold.py similarity index 95% rename from integration/test_legal_hold.py rename to tests/integration/test_legal_hold.py index 674be1600..f7af3cb18 100644 --- a/integration/test_legal_hold.py +++ b/tests/integration/test_legal_hold.py @@ -1,5 +1,5 @@ import pytest -from integration import run_command +from tests.integration import run_command LEGAL_HOLD_COMMAND = "code42 legal-hold" diff --git a/integration/util.py b/tests/integration/util.py similarity index 100% rename from integration/util.py rename to tests/integration/util.py diff --git a/tox.ini b/tox.ini index 68d680da2..83d505747 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,8 @@ commands = # -l: show locals in tracebacks # --tb=short: short traceback print mode # --strict: marks not registered in configuration file raise errors - pytest --cov=code42cli --cov-report xml -v -rsxX -l --tb=short --strict --ignore=integration + # --ignore=tests/integration: exclude integration tests + pytest --cov=code42cli --cov-report xml -v -rsxX -l --tb=short --strict --ignore=tests/integration [testenv:docs] deps = From 33c93a975026bf033e79fd097782cde6794a85ed Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 19 Jan 2021 14:51:17 -0600 Subject: [PATCH 165/349] Refactor magic date parsing & some search options (#198) * refactor magic date parsing into a click type, and make search options more flexible * remove redundant import * fix option ordering * make magic time period case-insensitive * allow case-insensitive periods in regex * inadvertent commit * set UTC timezone explicitly in MagicDate result * use correct method to set UTC (not convert to UTC) * add negative tests for MagicDate, adjust timestamp regex * use .timestamp() for converting from dt > timestamp * add note to changelog * fix style --- CHANGELOG.md | 2 + src/code42cli/click_ext/types.py | 100 ++++++++++++++++++ src/code42cli/cmds/alerts.py | 22 +++- src/code42cli/cmds/auditlogs.py | 40 +++----- src/code42cli/cmds/search/options.py | 53 ++-------- src/code42cli/cmds/securitydata.py | 36 +++++-- src/code42cli/date_helper.py | 89 ++++------------ src/code42cli/options.py | 64 ++++++------ tests/cmds/test_alerts.py | 28 +----- tests/cmds/test_auditlogs.py | 25 ++++- tests/cmds/test_securitydata.py | 2 +- tests/conftest.py | 1 + tests/test_date_helper.py | 87 ---------------- tests/test_magic_date_type.py | 145 +++++++++++++++++++++++++++ 14 files changed, 394 insertions(+), 300 deletions(-) delete mode 100644 tests/test_date_helper.py create mode 100644 tests/test_magic_date_type.py diff --git a/CHANGELOG.md b/CHANGELOG.md index adb01df76..f6570ccb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - The error text when removing an employee from a detection list now references the employee by ID rather the username. +- Improved help text for date option arguments. + ## 1.1.0 - 2020-12-18 ### Fixed diff --git a/src/code42cli/click_ext/types.py b/src/code42cli/click_ext/types.py index f2ba5ba25..607e9dc4e 100644 --- a/src/code42cli/click_ext/types.py +++ b/src/code42cli/click_ext/types.py @@ -1,4 +1,10 @@ +import re +from datetime import datetime +from datetime import timedelta +from datetime import timezone + import click +from click.exceptions import BadParameter class FileOrString(click.File): @@ -16,3 +22,97 @@ def convert(self, value, param, ctx): return file.read() else: return value + + +class MagicDate(click.ParamType): + """Declares a parameter to be a 'magic' date string. Accepts an optional `round` argument + which can be a function that takes a datetime and returns it rounded appropriately. This allows + imprecise "day" input values (2020-01-01, 3d) to be rounded to the start or end of the day + if needed. Accepts the following values as user input: + + timestamp formats: + yyyy-MM-dd + yyyy-MM-dd HH + yyyy-MM-dd HH:MM + yyyy-MM-dd HH:MM:SS + + short-string (day, hour, min) formats: + 30d + 24h + 15m + + and converts them to datetime objects. + """ + + TIMESTAMP_REGEX = re.compile(r"(\d{4}-\d{2}-\d{2})(?:$|T|\s+)([0-9:]+)?") + MAGIC_TIME_REGEX = re.compile(r"(\d+)([dhmDHM])$") + HELP_TEXT = ( + "Accepts a date/time in yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS " + "(UTC+24-hr time) format where the 'time' portion of the string " + "can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') or " + "a 'short time' value representing days (30d), hours (24h) or " + "minutes (15m) from the current time." + ) + + name = "magicdate" + + def __init__(self, rounding_func=None): + self.round = rounding_func + + def get_metavar(self, param): + return "[DATE|TIMESTAMP|SHORT_TIME]" + + def __repr__(self): + return "MagicDate" + + def convert(self, value, param, ctx): + timestamp_match = self.TIMESTAMP_REGEX.match(value) + magic_match = self.MAGIC_TIME_REGEX.match(value) + + if timestamp_match: + date, time = timestamp_match.groups() + dt = self._get_dt_from_date_time_pair(date, time) + if not time and callable(self.round): + dt = self.round(dt) + + elif magic_match: + num, period = magic_match.groups() + dt = self._get_dt_from_magic_time_pair(num, period) + if period == "d" and callable(self.round): + dt = self.round(dt) + + else: + self.fail(self.HELP_TEXT, param=param) + + return dt.replace(tzinfo=timezone.utc) + + @staticmethod + def _get_dt_from_magic_time_pair(num, period): + num = int(num) + period = period.lower() + if period == "d": + delta = timedelta(days=num) + elif period == "h": + delta = timedelta(hours=num) + elif period == "m": + delta = timedelta(minutes=num) + else: + raise BadParameter( + "Couldn't parse magic time string: {}{}".format(num, period) + ) + return datetime.utcnow() - delta + + @staticmethod + def _get_dt_from_date_time_pair(date, time): + date_format = "%Y-%m-%d %H:%M:%S" + if time: + time = "{}:{}:{}".format(*time.split(":") + ["00", "00"]) + else: + time = "00:00:00" + date_string = "{} {}".format(date, time) + try: + dt = datetime.strptime(date_string, date_format) + except ValueError: + raise BadParameter("Unable to parse date string: {}.".format(date_string)) + else: + return dt diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 9fddce042..557155742 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -14,13 +14,15 @@ import code42cli.options as opt from code42cli.cmds.search.cursor_store import AlertCursorStore from code42cli.cmds.search.extraction import handle_no_events +from code42cli.date_helper import convert_datetime_to_timestamp +from code42cli.date_helper import limit_date_range from code42cli.logger import get_logger_for_server from code42cli.options import format_option from code42cli.options import server_options from code42cli.output_formats import JsonOutputFormat from code42cli.output_formats import OutputFormatter - +ALERTS_KEYWORD = "alerts" SEARCH_DEFAULT_HEADER = OrderedDict() SEARCH_DEFAULT_HEADER["name"] = "RuleName" SEARCH_DEFAULT_HEADER["actor"] = "Username" @@ -30,7 +32,23 @@ SEARCH_DEFAULT_HEADER["description"] = "Description" -search_options = searchopt.create_search_options("alerts") +begin = opt.begin_option( + ALERTS_KEYWORD, + callback=lambda ctx, param, arg: convert_datetime_to_timestamp( + limit_date_range(arg, max_days_back=90) + ), +) +end = opt.end_option(ALERTS_KEYWORD) +checkpoint = opt.checkpoint_option(ALERTS_KEYWORD) +advanced_query = searchopt.advanced_query_option(ALERTS_KEYWORD) + + +def search_options(f): + f = checkpoint(f) + f = advanced_query(f) + f = end(f) + f = begin(f) + return f severity_option = click.option( diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index c22c4a6da..38c4d9fa6 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -4,14 +4,12 @@ import click +import code42cli.options as opt from code42cli.click_ext.groups import OrderedGroup from code42cli.cmds.search.cursor_store import AuditLogCursorStore -from code42cli.cmds.search.options import BeginOption -from code42cli.date_helper import parse_max_timestamp -from code42cli.date_helper import parse_min_timestamp +from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.logger import get_logger_for_server -from code42cli.options import begin_option -from code42cli.options import end_option +from code42cli.options import checkpoint_option from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.options import server_options @@ -31,7 +29,14 @@ AUDIT_LOGS_DEFAULT_HEADER["userName"] = "AffectedUser" AUDIT_LOGS_DEFAULT_HEADER["userId"] = "AffectedUserUID" - +begin_option = opt.begin_option( + AUDIT_LOGS_KEYWORD, + callback=lambda ctx, param, arg: convert_datetime_to_timestamp(arg), +) +end_option = opt.end_option( + AUDIT_LOGS_KEYWORD, + callback=lambda ctx, param, arg: convert_datetime_to_timestamp(arg), +) filter_option_usernames = click.option( "--actor-username", required=False, @@ -72,32 +77,17 @@ def filter_options(f): - f = begin_option( - f, - AUDIT_LOGS_KEYWORD, - callback=lambda ctx, param, arg: parse_min_timestamp(arg), - cls=BeginOption, - ) - f = end_option( - f, AUDIT_LOGS_KEYWORD, callback=lambda ctx, param, arg: parse_max_timestamp(arg) - ) f = filter_option_event_types(f) f = filter_option_usernames(f) f = filter_option_user_ids(f) f = filter_option_user_ip_addresses(f) f = filter_option_affected_user_ids(f) f = filter_option_affected_usernames(f) + f = end_option(f) + f = begin_option(f) return f -checkpoint_option = click.option( - "-c", - "--use-checkpoint", - metavar="checkpoint", - help="Only get audit-log events that were not previously retrieved.", -) - - @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def audit_logs(state): @@ -117,7 +107,7 @@ def clear_checkpoint(state, checkpoint_name): @audit_logs.command() @filter_options @format_option -@checkpoint_option +@checkpoint_option(AUDIT_LOGS_KEYWORD) @sdk_options() def search( state, @@ -170,7 +160,7 @@ def search( @audit_logs.command() @filter_options -@checkpoint_option +@checkpoint_option(AUDIT_LOGS_KEYWORD) @server_options @sdk_options() def send_to( diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index e664f590b..fc9f7e932 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -7,14 +7,6 @@ from code42cli.click_ext.options import incompatible_with from code42cli.click_ext.types import FileOrString -from code42cli.date_helper import parse_max_timestamp -from code42cli.date_helper import parse_min_timestamp -from code42cli.logger import get_main_cli_logger -from code42cli.options import begin_option -from code42cli.options import end_option - - -logger = get_main_cli_logger() def is_in_filter(filter_cls): @@ -135,49 +127,16 @@ def _parse_query_from_json(ctx, param, arg): ) -def search_interval_options(f, search_term): - f = begin_option( - f, - search_term, - cls=BeginOption, - callback=lambda ctx, param, arg: parse_min_timestamp(arg), - ) - f = end_option( - f, - search_term, - cls=AdvancedQueryAndSavedSearchIncompatible, - callback=lambda ctx, param, arg: parse_max_timestamp(arg), - ) - return f - - -def create_search_options(search_term): - - advanced_query_option = click.option( - "--advanced-query", - help="A raw JSON {} query. " +def advanced_query_option(term, **kwargs): + defaults = dict( + help=f"A raw JSON {term} query. " "Useful for when the provided query parameters do not satisfy your requirements. " "Argument can be passed as a string, read from stdin by passing '-', or from a filename if " "prefixed with '@', e.g. '--advanced-query @query.json'. " - "WARNING: Using advanced queries is incompatible with other query-building arguments.".format( - search_term - ), + "WARNING: Using advanced queries is incompatible with other query-building arguments.", metavar="QUERY_JSON", type=FileOrString(), callback=_parse_query_from_json, ) - checkpoint_option = click.option( - "-c", - "--use-checkpoint", - help="Only get {} that were not previously retrieved.".format(search_term), - cls=incompatible_with("saved_search"), - ) - - def search_options(f): - - f = search_interval_options(f, search_term) - f = checkpoint_option(f) - f = advanced_query_option(f) - return f - - return search_options + defaults.update(kwargs) + return click.option("--advanced-query", **defaults) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 5bc0b3233..4b1d137ad 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -12,21 +12,22 @@ import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt import code42cli.errors as errors +import code42cli.options as opt from code42cli.click_ext.groups import OrderedGroup from code42cli.click_ext.options import incompatible_with from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.cmds.search.extraction import handle_no_events from code42cli.cmds.securitydata_output_formats import FileEventsOutputFormatter +from code42cli.date_helper import convert_datetime_to_timestamp +from code42cli.date_helper import limit_date_range from code42cli.logger import get_logger_for_server -from code42cli.logger import get_main_cli_logger from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.options import send_to_format_options from code42cli.options import server_options from code42cli.output_formats import OutputFormatter -logger = get_main_cli_logger() - +SECURITY_DATA_KEYWORD = "file events" _HEADER_KEYS_MAP = OrderedDict() _HEADER_KEYS_MAP["name"] = "Name" _HEADER_KEYS_MAP["id"] = "Id" @@ -50,11 +51,6 @@ help="The output format of the result. Defaults to table format.", default=enum.FileEventsOutputFormat.TABLE, ) - - -search_options = searchopt.create_search_options("file events") - - exposure_type_option = click.option( "-t", "--type", @@ -160,6 +156,26 @@ def _get_saved_search_query(ctx, param, arg): cls=incompatible_with("advanced_query"), ) +begin_option = opt.begin_option( + SECURITY_DATA_KEYWORD, + callback=lambda ctx, param, arg: convert_datetime_to_timestamp( + limit_date_range(arg, max_days_back=90) + ), +) +end_option = opt.end_option(SECURITY_DATA_KEYWORD) +checkpoint_option = opt.checkpoint_option( + SECURITY_DATA_KEYWORD, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible +) +advanced_query_option = searchopt.advanced_query_option(SECURITY_DATA_KEYWORD) + + +def search_options(f): + f = checkpoint_option(f) + f = advanced_query_option(f) + f = end_option(f) + f = begin_option(f) + return f + def file_event_options(f): f = exposure_type_option(f) @@ -236,7 +252,7 @@ def search( saved_search, or_query, include_all, - **kwargs + **kwargs, ): """Search for file events.""" output_header = ext.try_get_default_header( @@ -319,7 +335,7 @@ def send_to( use_checkpoint, saved_search, or_query, - **kwargs + **kwargs, ): """Send events to the given server address.""" logger = get_logger_for_server(hostname, protocol, format) diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py index 138f6b15b..251882b2b 100644 --- a/src/code42cli/date_helper.py +++ b/src/code42cli/date_helper.py @@ -1,9 +1,9 @@ import re from datetime import datetime from datetime import timedelta +from datetime import timezone import click -from c42eventextractor.common import convert_datetime_to_timestamp TIMESTAMP_REGEX = re.compile(r"(\d{4}-\d{2}-\d{2})\s*(.*)?") MAGIC_TIME_REGEX = re.compile(r"(\d+)([dhm])$") @@ -15,90 +15,37 @@ ) -def verify_timestamp_order(min_timestamp, max_timestamp): - if min_timestamp is None or max_timestamp is None: +def convert_datetime_to_timestamp(dt): + if dt is None: return - if min_timestamp >= max_timestamp: - raise click.BadParameter( - param_hint=["-b", "--begin"], message="cannot be after --end date." - ) + return dt.replace(tzinfo=timezone.utc).timestamp() -def parse_min_timestamp(begin_date_str, max_days_back=90): - if begin_date_str is None: +def verify_timestamp_order( + min_timestamp, max_timestamp, min_param=("-b", "--begin"), max_param="--end" +): + if min_timestamp is None or max_timestamp is None: return - dt = _parse_timestamp(begin_date_str, _round_datetime_to_day_start) - boundary_date = _round_datetime_to_day_start( - datetime.utcnow() - timedelta(days=max_days_back) - ) - if dt < boundary_date: + if min_timestamp >= max_timestamp: raise click.BadParameter( - message="must be within {} days.".format(max_days_back) + param_hint=min_param, message=f"cannot be after {max_param} date." ) - return convert_datetime_to_timestamp(dt) -def parse_max_timestamp(end_date_str): - if end_date_str is None: +def limit_date_range(dt, max_days_back=90, param=None): + if dt is None: return - dt = _parse_timestamp(end_date_str, _round_datetime_to_day_end) - return convert_datetime_to_timestamp(dt) - - -def _parse_timestamp(date_str, rounding_func): - timestamp_match = TIMESTAMP_REGEX.match(date_str) - magic_match = MAGIC_TIME_REGEX.match(date_str) - - if timestamp_match: - date, time = timestamp_match.groups() - dt = _get_dt_from_date_time_pair(date, time) - if not time: - dt = rounding_func(dt) - - elif magic_match: - num, period = magic_match.groups() - dt = _get_dt_from_magic_time_pair(num, period) - if period == "d": - dt = rounding_func(dt) - - else: - raise click.BadParameter(message=_FORMAT_VALUE_ERROR_MESSAGE) - return dt - - -def _get_dt_from_date_time_pair(date, time): - date_format = "%Y-%m-%d %H:%M:%S" - if time: - time = "{}:{}:{}".format(*time.split(":") + ["00", "00"]) - else: - time = "00:00:00" - date_string = "{} {}".format(date, time) - try: - dt = datetime.strptime(date_string, date_format) - except ValueError: - raise click.ClickException("Unable to parse date string.") - else: - return dt - - -def _get_dt_from_magic_time_pair(num, period): - num = int(num) - if period == "d": - dt = datetime.utcnow() - timedelta(days=num) - elif period == "h": - dt = datetime.utcnow() - timedelta(hours=num) - elif period == "m": - dt = datetime.utcnow() - timedelta(minutes=num) - else: - raise click.ClickException( - "Couldn't parse magic time string: {}{}".format(num, period) + now = datetime.utcnow().replace(tzinfo=timezone.utc) + if now - dt > timedelta(days=max_days_back): + raise click.BadParameter( + message="must be within {} days.".format(max_days_back), param=param ) return dt -def _round_datetime_to_day_start(dt): +def round_datetime_to_day_start(dt): return dt.replace(hour=0, minute=0, second=0, microsecond=0) -def _round_datetime_to_day_end(dt): +def round_datetime_to_day_end(dt): return dt.replace(hour=23, minute=59, second=59, microsecond=999000) diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 48b2352ca..896664b1e 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -1,24 +1,18 @@ import click +from code42cli.click_ext.types import MagicDate from code42cli.cmds.search.enums import ServerProtocol +from code42cli.cmds.search.options import AdvancedQueryAndSavedSearchIncompatible +from code42cli.cmds.search.options import BeginOption +from code42cli.date_helper import convert_datetime_to_timestamp +from code42cli.date_helper import round_datetime_to_day_end +from code42cli.date_helper import round_datetime_to_day_start from code42cli.errors import Code42CLIError from code42cli.output_formats import OutputFormat from code42cli.output_formats import SendToFileEventsOutputFormat from code42cli.profile import get_profile from code42cli.sdk_client import create_sdk -BEGIN_OPTION_HELP_MESSAGE = ( - "The beginning of the date range in which to look for {}, can be a date/time in " - "yyyy-MM-dd (UTC) or yyyy-MM-dd HH:MM:SS (UTC+24-hr time) format where the 'time' " - "portion of the string can be partial (e.g. '2020-01-01 12' or '2020-01-01 01:15') " - "or a short value representing days (30d), hours (24h) or minutes (15m) from current " - "time." -) - -END_OPTION_HELP_MESSAGE = ( - "The end of the date range in which to look for {}, argument format options are " - "the same as `--begin`." -) yes_option = click.option( "-y", @@ -38,23 +32,6 @@ ) -def begin_option(f, search_term, **kwargs): - start_time = click.option( - "-b", "--begin", help=BEGIN_OPTION_HELP_MESSAGE.format(search_term), **kwargs - ) - - f = start_time(f) - return f - - -def end_option(f, search_term, **kwargs): - end_time = click.option( - "-e", "--end", help=END_OPTION_HELP_MESSAGE.format(search_term), **kwargs - ) - f = end_time(f) - return f - - class CLIState: def __init__(self): try: @@ -159,3 +136,32 @@ def server_options(f): help="The output format of the result. Defaults to json format.", default=SendToFileEventsOutputFormat.RAW, ) + + +def begin_option(term, **kwargs): + defaults = dict( + type=MagicDate(rounding_func=round_datetime_to_day_start), + help=f"The beginning of the date range in which to look for {term}. {MagicDate.HELP_TEXT}", + cls=BeginOption, + callback=lambda ctx, param, arg: convert_datetime_to_timestamp(arg), + ) + defaults.update(kwargs) + return click.option("-b", "--begin", **defaults) + + +def end_option(term, **kwargs): + defaults = dict( + type=MagicDate(rounding_func=round_datetime_to_day_end), + cls=AdvancedQueryAndSavedSearchIncompatible, + help=f"The end of the date range in which to look for {term}, argument format options are " + "the same as `--begin`.", + callback=lambda ctx, param, arg: convert_datetime_to_timestamp(arg), + ) + defaults.update(kwargs) + return click.option("-e", "--end", **defaults) + + +def checkpoint_option(term, **kwargs): + defaults = dict(help=f"Only get {term} that were not previously retrieved.") + defaults.update(kwargs) + return click.option("-c", "--use-checkpoint", **defaults) diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index e9d8139bd..4f12c058f 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -215,7 +215,7 @@ def alert_cursor_without_checkpoint(mocker): @pytest.fixture def begin_option(mocker): mock = mocker.patch( - "{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME) + "{}.cmds.alerts.convert_datetime_to_timestamp".format(PRODUCT_NAME) ) mock.return_value = BEGIN_TIMESTAMP mock.expected_timestamp = "2020-01-01T06:00:00.000Z" @@ -451,9 +451,7 @@ def test_get_alert_details_sorts_results_by_date(sdk): def test_search_with_only_begin_calls_extract_with_expected_filters( cli_state, alert_extractor, begin_option, runner ): - result = runner.invoke( - cli, ["alerts", "search", "--begin", ""], obj=cli_state - ) + result = runner.invoke(cli, ["alerts", "search", "--begin", "1d"], obj=cli_state) assert result.exit_code == 0 assert str( alert_extractor.extract.call_args[0][0] @@ -480,14 +478,7 @@ def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract ): result = runner.invoke( cli, - [ - "alerts", - "search", - "--use-checkpoint", - "test", - "--begin", - "", - ], + ["alerts", "search", "--use-checkpoint", "test", "--begin", "1d"], obj=cli_state, ) assert result.exit_code == 0 @@ -929,9 +920,7 @@ def test_send_to_with_only_begin_calls_extract_with_expected_filters( cli_state, alert_extractor, begin_option, runner ): result = runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", ""], - obj=cli_state, + cli, ["alerts", "send-to", "0.0.0.0", "--begin", "1d"], obj=cli_state, ) assert result.exit_code == 0 assert str( @@ -959,14 +948,7 @@ def test_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls ): result = runner.invoke( cli, - [ - "alerts", - "search", - "--use-checkpoint", - "test", - "--begin", - "", - ], + ["alerts", "search", "--use-checkpoint", "test", "--begin", "1d"], obj=cli_state, ) assert result.exit_code == 0 diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index dab5f0022..2c9ca9436 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -7,10 +7,12 @@ from py42.response import Py42Response from requests import Response +from code42cli.click_ext.types import MagicDate from code42cli.cmds.auditlogs import _parse_audit_log_timestamp_string_to_timestamp from code42cli.cmds.search.cursor_store import AuditLogCursorStore -from code42cli.date_helper import parse_max_timestamp -from code42cli.date_helper import parse_min_timestamp +from code42cli.date_helper import convert_datetime_to_timestamp +from code42cli.date_helper import round_datetime_to_day_end +from code42cli.date_helper import round_datetime_to_day_start from code42cli.main import cli from code42cli.util import hash_event @@ -139,6 +141,11 @@ def test_search_audit_logs_json_format(runner, cli_state, date_str): def test_search_audit_logs_with_filter_parameters(runner, cli_state, date_str): + expected_begin_timestamp = convert_datetime_to_timestamp( + MagicDate(rounding_func=round_datetime_to_day_start).convert( + date_str, None, None + ) + ) runner.invoke( cli, [ @@ -158,7 +165,7 @@ def test_search_audit_logs_with_filter_parameters(runner, cli_state, date_str): usernames=("test@test.com", "test2@test.test"), affected_user_ids=(), affected_usernames=(), - begin_time=parse_min_timestamp(date_str), + begin_time=expected_begin_timestamp, end_time=None, event_types=(), user_ids=(), @@ -168,6 +175,14 @@ def test_search_audit_logs_with_filter_parameters(runner, cli_state, date_str): def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_str): end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + expected_begin_timestamp = convert_datetime_to_timestamp( + MagicDate(rounding_func=round_datetime_to_day_start).convert( + date_str, None, None + ) + ) + expected_end_timestamp = convert_datetime_to_timestamp( + MagicDate(rounding_func=round_datetime_to_day_end).convert(end_time, None, None) + ) runner.invoke( cli, [ @@ -201,8 +216,8 @@ def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_st usernames=("test@test.com", "test2@test.test"), affected_user_ids=("123", "456"), affected_usernames=("test@test.test",), - begin_time=parse_min_timestamp(date_str), - end_time=parse_max_timestamp(end_time), + begin_time=expected_begin_timestamp, + end_time=expected_end_timestamp, event_types=("saved-search",), user_ids=("userid",), user_ip_addresses=("0.0.0.0",), diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 79fd7fa0e..6445eb061 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -129,7 +129,7 @@ def file_event_cursor_without_checkpoint(mocker): @pytest.fixture def begin_option(mocker): mock = mocker.patch( - "{}.cmds.search.options.parse_min_timestamp".format(PRODUCT_NAME) + "{}.cmds.securitydata.convert_datetime_to_timestamp".format(PRODUCT_NAME) ) mock.return_value = BEGIN_TIMESTAMP mock.expected_timestamp = "2020-01-01T06:00:00.000Z" diff --git a/tests/conftest.py b/tests/conftest.py index 6d7c17c68..d4ef1d0b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -228,6 +228,7 @@ def get_test_date_str(days_ago): begin_date_str = get_test_date_str(days_ago=89) begin_date_str_with_time = "{} 3:12:33".format(begin_date_str) +begin_date_str_with_t_time = "{}T3:12:33".format(begin_date_str) end_date_str = get_test_date_str(days_ago=10) end_date_str_with_time = "{} 11:22:43".format(end_date_str) begin_date_str = get_test_date_str(days_ago=89) diff --git a/tests/test_date_helper.py b/tests/test_date_helper.py deleted file mode 100644 index 7846b30c6..000000000 --- a/tests/test_date_helper.py +++ /dev/null @@ -1,87 +0,0 @@ -from datetime import datetime - -from c42eventextractor.common import convert_datetime_to_timestamp - -from .conftest import begin_date_str -from .conftest import begin_date_str_with_time -from .conftest import end_date_str -from .conftest import end_date_str_with_time -from .conftest import get_test_date -from code42cli.date_helper import parse_max_timestamp -from code42cli.date_helper import parse_min_timestamp - - -def test_parse_min_timestamp_when_given_date_str_parses_successfully(): - actual = parse_min_timestamp(begin_date_str) - expected = convert_datetime_to_timestamp( - datetime.strptime(begin_date_str, "%Y-%m-%d") - ) - assert actual == expected - - -def test_parse_min_timestamp_when_given_date_str_with_time_parses_successfully(): - actual = parse_min_timestamp(begin_date_str_with_time) - expected = convert_datetime_to_timestamp( - datetime.strptime(begin_date_str_with_time, "%Y-%m-%d %H:%M:%S") - ) - assert actual == expected - - -def test_parse_min_timestamp_when_given_magic_days_parses_successfully(): - actual_date = datetime.utcfromtimestamp(parse_min_timestamp("20d")) - expected_date = datetime.utcfromtimestamp( - convert_datetime_to_timestamp(get_test_date(days_ago=20)) - ) - expected_date = expected_date.replace(hour=0, minute=0, second=0, microsecond=0) - assert actual_date == expected_date - - -def test_parse_min_timestamp_when_given_magic_hours_parses_successfully(): - actual = parse_min_timestamp("20h") - expected = convert_datetime_to_timestamp(get_test_date(hours_ago=20)) - assert expected - actual < 0.01 - - -def test_parse_min_timestamp_when_given_magic_minutes_parses_successfully(): - actual = parse_min_timestamp("20m") - expected = convert_datetime_to_timestamp(get_test_date(minutes_ago=20)) - assert expected - actual < 0.01 - - -def test_parse_max_timestamp_when_given_date_str_parses_successfully(): - actual = parse_min_timestamp(end_date_str) - expected = convert_datetime_to_timestamp( - datetime.strptime(end_date_str, "%Y-%m-%d") - ) - assert actual == expected - - -def test_parse_max_timestamp_when_given_date_str_with_time_parses_successfully(): - actual = parse_min_timestamp(end_date_str_with_time) - expected = convert_datetime_to_timestamp( - datetime.strptime(end_date_str_with_time, "%Y-%m-%d %H:%M:%S") - ) - assert actual == expected - - -def test_parse_max_timestamp_when_given_magic_days_parses_successfully(): - actual_date = datetime.utcfromtimestamp(parse_max_timestamp("20d")) - expected_date = datetime.utcfromtimestamp( - convert_datetime_to_timestamp(get_test_date(days_ago=20)) - ) - expected_date = expected_date.replace( - hour=23, minute=59, second=59, microsecond=999000 - ) - assert actual_date == expected_date - - -def test_parse_max_timestamp_when_given_magic_hours_parses_successfully(): - actual = parse_max_timestamp("20h") - expected = convert_datetime_to_timestamp(get_test_date(hours_ago=20)) - assert expected - actual < 0.01 - - -def test_parse_magic_minutes_parses_successfully(): - actual = parse_max_timestamp("20m") - expected = convert_datetime_to_timestamp(get_test_date(minutes_ago=20)) - assert expected - actual < 0.01 diff --git a/tests/test_magic_date_type.py b/tests/test_magic_date_type.py new file mode 100644 index 000000000..0919582e7 --- /dev/null +++ b/tests/test_magic_date_type.py @@ -0,0 +1,145 @@ +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +import pytest +from click.exceptions import BadParameter + +from .conftest import begin_date_str +from .conftest import begin_date_str_with_t_time +from .conftest import begin_date_str_with_time +from .conftest import end_date_str +from .conftest import end_date_str_with_time +from .conftest import get_test_date +from code42cli.click_ext.types import MagicDate +from code42cli.date_helper import round_datetime_to_day_end +from code42cli.date_helper import round_datetime_to_day_start + +one_ms = timedelta(milliseconds=1) + + +def utc(dt): + return dt.replace(tzinfo=timezone.utc) + + +class TestMagicDateNoRounding: + md = MagicDate() + + def convert(self, val): + return self.md.convert(val, ctx=None, param=None) + + def test_when_given_date_str_parses_successfully(self): + actual = self.convert(begin_date_str) + expected = utc(datetime.strptime(begin_date_str, "%Y-%m-%d")) + assert actual == expected + + @pytest.mark.parametrize( + "param", [begin_date_str_with_time, begin_date_str_with_t_time], + ) + def test_when_given_date_str_with_time_parses_successfully(self, param): + actual = self.convert(param) + expected = utc(datetime.strptime(begin_date_str_with_time, "%Y-%m-%d %H:%M:%S")) + assert actual == expected + + @pytest.mark.parametrize("param", ["20d", "20D"]) + def test_when_given_magic_days_parses_successfully(self, param): + actual_date = self.convert(param) + expected_date = utc(get_test_date(days_ago=20)) + assert actual_date - expected_date < one_ms + + @pytest.mark.parametrize("param", ["20h", "20H"]) + def test_when_given_magic_hours_parses_successfully(self, param): + actual = self.convert(param) + expected = utc(get_test_date(hours_ago=20)) + assert expected - actual < one_ms + + @pytest.mark.parametrize("param", ["20m", "20M"]) + def test_when_given_magic_minutes_parses_successfully(self, param): + actual = self.convert(param) + expected = utc(get_test_date(minutes_ago=20)) + assert expected - actual < one_ms + + @pytest.mark.parametrize( + "badparam", + [ + "20days", + "20S", + "d20", + "01-01-2020", + "2020-01-0110:10:10", + "2020-01-01 30:30:30", + ], + ) + def test_when_given_bad_values_raises_exception(self, badparam): + with pytest.raises(BadParameter): + self.convert(badparam) + + +class TestMagicDateRoundingToStart: + md = MagicDate(rounding_func=round_datetime_to_day_start) + + def convert(self, val): + return self.md.convert(val, ctx=None, param=None) + + def test_when_given_date_str_parses_successfully(self): + actual = self.convert(begin_date_str) + expected = utc(datetime.strptime(begin_date_str, "%Y-%m-%d")) + assert actual == expected + + def test_when_given_date_str_with_time_parses_successfully(self,): + actual = self.convert(begin_date_str_with_time) + expected = utc(datetime.strptime(begin_date_str_with_time, "%Y-%m-%d %H:%M:%S")) + assert actual == expected + + def test_when_given_magic_days_parses_successfully(self): + actual_date = self.convert("20d") + expected_date = utc(get_test_date(days_ago=20)) + assert actual_date - expected_date < one_ms + + def test_when_given_magic_hours_parses_successfully(self): + actual = self.convert("20h") + expected = utc(get_test_date(hours_ago=20)) + assert expected - actual < one_ms + + def test_when_given_magic_minutes_parses_successfully(self): + actual = self.convert("20m") + expected = utc(get_test_date(minutes_ago=20)) + assert expected - actual < one_ms + + +class TestMagicDateRoundingToEnd: + md = MagicDate(rounding_func=round_datetime_to_day_end) + + def convert(self, val): + return self.md.convert(val, ctx=None, param=None) + + def test_when_given_date_str_parses_successfully(self): + actual = self.convert(end_date_str) + expected = datetime.strptime(end_date_str, "%Y-%m-%d") + expected = utc( + expected.replace(hour=23, minute=59, second=59, microsecond=999000) + ) + assert actual == expected + + def test_when_given_date_str_with_time_parses_successfully(self): + actual = self.convert(end_date_str_with_time) + expected = utc(datetime.strptime(end_date_str_with_time, "%Y-%m-%d %H:%M:%S")) + assert actual == expected + + def test_when_given_magic_days_parses_successfully(self): + actual_date = self.convert("20d") + expected_date = get_test_date(days_ago=20) + expected_date = utc( + expected_date.replace(hour=23, minute=59, second=59, microsecond=999000) + ) + assert actual_date == expected_date + + def test_when_given_magic_hours_parses_successfully(self): + actual = self.convert("20h") + expected = utc(get_test_date(hours_ago=20)) + assert expected - actual < one_ms + + def test_when_given_magic_minutes_parses_successfully(self): + actual = self.convert("20m") + expected = utc(get_test_date(minutes_ago=20)) + assert expected - actual < one_ms From 07a0e9e8b31311760a75137f2a8ca93966e527f2 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Thu, 21 Jan 2021 10:35:01 +0530 Subject: [PATCH 166/349] Feature cases (#199) * added cases support * Refactored * added change log * add cases docs * improvize docs * fix change in param names in py42 * Fix trivial stuff * add format option for list and show events * Add tests * Added tests * Added more tests * added missing periods * added type to date fields * added integration tests for cases * fix style * make file creation os independent * use MagicDate format for begin and end-time * provide --include-file-events option in show * remove file-events show * fix tests * added changelog * Capitalize * fix tests * change py42 dependency * use code42clierror * fix looping bug * prepend new line --- CHANGELOG.md | 12 ++ docs/commands.md | 1 + docs/commands/cases.rst | 3 + setup.py | 2 +- src/code42cli/cmds/cases.py | 247 ++++++++++++++++++++++++++++ src/code42cli/main.py | 2 + src/code42cli/options.py | 17 ++ tests/cmds/test_cases.py | 279 ++++++++++++++++++++++++++++++++ tests/integration/test_cases.py | 31 ++++ 9 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 docs/commands/cases.rst create mode 100644 src/code42cli/cmds/cases.py create mode 100644 tests/cmds/test_cases.py create mode 100644 tests/integration/test_cases.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6570ccb9..9fb6c8463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,18 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 high-risk-employee list` command. +- `code42 cases` commands: + - `create` to create a new case. + - `update` to update case details. + - `export` to download case summary in a pdf file. + - `list` to view all the cases. + - `show` to view details of a particular case. + +- `code42 cases file-events` commands: + - `add` to add an event to a case. + - `remove` to remove an event from the case. + - `list` to view all events assocaited to the case. + ### Changed - The error text when removing an employee from a detection list now references the employee diff --git a/docs/commands.md b/docs/commands.md index adb292a39..97eeed4c1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -8,3 +8,4 @@ * [Departing Employee](commands/departingemployee.rst) * [High Risk Employee](commands/highriskemployee.rst) * [Legal Hold](commands/legalhold.rst) +* [Cases](commands/cases.rst) diff --git a/docs/commands/cases.rst b/docs/commands/cases.rst new file mode 100644 index 000000000..714bf34f4 --- /dev/null +++ b/docs/commands/cases.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.cases:cases + :prog: cases + :show-nested: diff --git a/setup.py b/setup.py index ebe18873f..68a782df4 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ "keyring==18.0.1", "keyrings.alt==3.2.0", "pandas>=1.1.3", - "py42>=1.10", + "py42>=1.11", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py new file mode 100644 index 000000000..fb4cb472e --- /dev/null +++ b/src/code42cli/cmds/cases.py @@ -0,0 +1,247 @@ +import json +import os +from pprint import pformat + +import click +from py42.clients.cases import CaseStatus +from py42.exceptions import Py42BadRequestError +from py42.exceptions import Py42NotFoundError + +from code42cli.click_ext.groups import OrderedGroup +from code42cli.errors import Code42CLIError +from code42cli.options import format_option +from code42cli.options import sdk_options +from code42cli.options import set_begin_default_dict +from code42cli.options import set_end_default_dict +from code42cli.output_formats import OutputFormatter + + +case_number_arg = click.argument("case-number", type=int) +name_option = click.option("--name", help="Name of the case.",) +assignee_option = click.option("--assignee", help="User UID of the assignee.") +description_option = click.option("--description", help="Description of the case.") +notes_option = click.option("--notes", help="Notes on the case.") +subject_option = click.option("--subject", help="User UID of a subject of the case.") +status_option = click.option( + "--status", + help="Status of the case. `OPEN` or `CLOSED`.", + type=click.Choice(CaseStatus.choices()), +) +file_event_id_option = click.option( + "--event-id", required=True, help="File event id associated to the case." +) + +CASES_KEYWORD = "cases" +BEGIN_DATE_DICT = set_begin_default_dict(CASES_KEYWORD) +END_DATE_DICT = set_end_default_dict(CASES_KEYWORD) + + +def _get_cases_header(): + return { + "number": "Number", + "name": "Name", + "assignee": "Assignee", + "status": "Status", + "createdAt": "Creation Time", + "findings": "Notes", + } + + +def _get_events_header(): + return { + "eventId": "Event Id", + "eventTimestatmp": "Timestamp", + "filePath": "Path", + "fileName": "File", + "exposure": "Exposure", + } + + +@click.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def cases(state): + """For managing cases and events associated with cases.""" + pass + + +@cases.command() +@click.argument("name") +@assignee_option +@description_option +@notes_option +@subject_option +@sdk_options() +def create(state, name, subject, assignee, description, notes): + """Create a new case.""" + state.sdk.cases.create( + name, + subject=subject, + assignee=assignee, + description=description, + findings=notes, + ) + + +@cases.command() +@case_number_arg +@name_option +@assignee_option +@description_option +@notes_option +@subject_option +@status_option +@sdk_options() +def update(state, case_number, name, subject, assignee, description, notes, status): + """Update case details for the given case.""" + state.sdk.cases.update( + case_number, + name=name, + subject=subject, + assignee=assignee, + description=description, + findings=notes, + status=status, + ) + + +@cases.command("list") +@click.option( + "--name", help="Filter by name of a case, supports partial name matches.", +) +@click.option("--subject", help="Filter by user UID of the subject of a case.") +@click.option("--assignee", help="Filter by user UID of assignee.") +@click.option("--begin-create-time", **BEGIN_DATE_DICT) +@click.option("--end-create-time", **END_DATE_DICT) +@click.option("--begin-update-time", **BEGIN_DATE_DICT) +@click.option("--end-update-time", **END_DATE_DICT) +@click.option("--status", help="Filter cases by case status.") +@format_option +@sdk_options() +def _list( + state, + name, + assignee, + subject, + begin_create_time, + end_create_time, + begin_update_time, + end_update_time, + status, + format, +): + """List all the cases.""" + pages = state.sdk.cases.get_all( + name=name, + assignee=assignee, + subject=subject, + min_create_time=begin_create_time, + max_create_time=end_create_time, + min_update_time=begin_update_time, + max_update_time=end_update_time, + status=status, + ) + formatter = OutputFormatter(format, _get_cases_header()) + cases = [case for page in pages for case in page["cases"]] + if cases: + formatter.echo_formatted_list(cases) + else: + click.echo("No cases found.") + + +def _get_file_events(sdk, case_number): + response = sdk.cases.file_events.get_all(case_number) + if not response["events"]: + return None + return json.loads(response.text) + + +def _display_file_events(events): + if events: + click.echo("\nFile Events:\n") + click.echo(pformat(events)) + else: + click.echo("\nNo events found.") + + +@cases.command() +@case_number_arg +@click.option( + "--include-file-events", is_flag=True, help="View events associated to the case." +) +@sdk_options() +@format_option +def show(state, case_number, format, include_file_events): + """Show case details.""" + formatter = OutputFormatter(format, _get_cases_header()) + try: + response = state.sdk.cases.get(case_number) + formatter.echo_formatted_list([response.data]) + if include_file_events: + events = _get_file_events(state.sdk, case_number) + _display_file_events(events) + except Py42NotFoundError: + raise Code42CLIError("Invalid case-number {}.".format(case_number)) + + +@cases.command() +@case_number_arg +@click.option( + "--path", help="File path. Defaults to the current directory.", default="." +) +@sdk_options() +def export(state, case_number, path): + """Download a case detail summary as a pdf file at the given path with name _case_summary.pdf.""" + response = state.sdk.cases.export_summary(case_number) + file = os.path.join(path, "{}_case_summary.pdf".format(case_number)) + with open(file, "wb") as f: + f.write(response.content) + + +@cases.group(cls=OrderedGroup) +@sdk_options() +def file_events(state): + """Fetch file events associated with the case.""" + pass + + +@file_events.command("list") +@case_number_arg +@sdk_options() +@format_option +def file_events_list(state, case_number, format): + """List all the events associated with the case.""" + formatter = OutputFormatter(format, _get_events_header()) + try: + response = state.sdk.cases.file_events.get_all(case_number) + except Py42NotFoundError: + raise Code42CLIError("Invalid case-number.") + + if not response["events"]: + click.echo("No events found.") + else: + events = [event for event in response["events"]] + formatter.echo_formatted_list(events) + + +@file_events.command() +@case_number_arg +@file_event_id_option +@sdk_options() +def add(state, case_number, event_id): + """Associate an event id to a case.""" + try: + state.sdk.cases.file_events.add(case_number, event_id) + except Py42BadRequestError: + raise Code42CLIError("Invalid case-number or event-id.") + + +@file_events.command() +@case_number_arg +@file_event_id_option +@sdk_options() +def remove(state, case_number, event_id): + """Remove the associated event id from the case.""" + try: + state.sdk.cases.file_events.delete(case_number, event_id) + except Py42NotFoundError: + raise Code42CLIError("Invalid case-number or event-id.") diff --git a/src/code42cli/main.py b/src/code42cli/main.py index f09925eb9..2c6c5bba1 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -11,6 +11,7 @@ from code42cli.cmds.alert_rules import alert_rules from code42cli.cmds.alerts import alerts from code42cli.cmds.auditlogs import audit_logs +from code42cli.cmds.cases import cases from code42cli.cmds.departing_employee import departing_employee from code42cli.cmds.devices import devices from code42cli.cmds.high_risk_employee import high_risk_employee @@ -64,3 +65,4 @@ def cli(state): cli.add_command(profile) cli.add_command(devices) cli.add_command(audit_logs) +cli.add_command(cases) diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 896664b1e..37ff56747 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -165,3 +165,20 @@ def checkpoint_option(term, **kwargs): defaults = dict(help=f"Only get {term} that were not previously retrieved.") defaults.update(kwargs) return click.option("-c", "--use-checkpoint", **defaults) + + +def set_begin_default_dict(term): + return dict( + type=MagicDate(rounding_func=round_datetime_to_day_start), + help=f"The beginning of the date range in which to look for {term}. {MagicDate.HELP_TEXT}", + callback=lambda ctx, param, arg: convert_datetime_to_timestamp(arg), + ) + + +def set_end_default_dict(term): + return dict( + type=MagicDate(rounding_func=round_datetime_to_day_end), + help=f"The end of the date range in which to look for {term}, argument format options are " + "the same as `--begin`.", + callback=lambda ctx, param, arg: convert_datetime_to_timestamp(arg), + ) diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py new file mode 100644 index 000000000..231f8181e --- /dev/null +++ b/tests/cmds/test_cases.py @@ -0,0 +1,279 @@ +import json +from unittest import mock +from unittest.mock import mock_open + +import pytest +from py42.exceptions import Py42BadRequestError +from py42.exceptions import Py42NotFoundError +from py42.response import Py42Response + +from code42cli.main import cli + + +EVENT_DETAILS = """{"eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971"} +""" + +ALL_EVENTS = """{"events": [{"eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971"}]}""" + +ALL_CASES = """{"cases": [{"number": 3,"name": "test@test.test"}], "totalCount": 31}""" + +CASE_DETAILS = """{"number": 3, "name": "test@test.test"}""" + +CASES_COMMAND = "cases" +CASES_FILE_EVENTS_COMMAND = "cases file-events" + +MISSING_ARGUMENT_ERROR = "Missing argument '{}'." +MISSING_NAME = MISSING_ARGUMENT_ERROR.format("NAME") +MISSING_CASE_NUMBER = MISSING_ARGUMENT_ERROR.format("CASE_NUMBER") +MISSING_OPTION_ERROR = "Missing option '--{}'." +MISSING_EVENT_ID = MISSING_OPTION_ERROR.format("event-id") + + +@pytest.fixture +def error(mocker): + error = mocker.Mock(spec=Exception) + error.response = "error" + return error + + +@pytest.fixture +def py42_response(mocker): + return mocker.MagicMock(spec=Py42Response) + + +def test_create_calls_create_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["cases", "create", "TEST_CASE"], obj=cli_state, + ) + cli_state.sdk.cases.create.assert_called_once_with( + "TEST_CASE", assignee=None, description=None, findings=None, subject=None + ) + + +def test_create_with_optional_fields_calls_create_with_expected_params( + runner, cli_state +): + runner.invoke( + cli, + [ + "cases", + "create", + "TEST_CASE", + "--assignee", + "a", + "--description", + "d", + "--notes", + "n", + "--subject", + "s", + ], + obj=cli_state, + ) + cli_state.sdk.cases.create.assert_called_once_with( + "TEST_CASE", assignee="a", description="d", findings="n", subject="s" + ) + + +def test_update_with_optional_fields_calls_update_with_expected_params( + runner, cli_state +): + runner.invoke( + cli, + [ + "cases", + "update", + "1", + "--name", + "TEST_CASE2", + "--assignee", + "a", + "--description", + "d", + "--notes", + "n", + "--subject", + "s", + "--status", + "CLOSED", + ], + obj=cli_state, + ) + cli_state.sdk.cases.update.assert_called_once_with( + 1, + name="TEST_CASE2", + status="CLOSED", + assignee="a", + description="d", + findings="n", + subject="s", + ) + + +def test_update_calls_update_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["cases", "update", "1", "--name", "TEST_CASE2"], obj=cli_state, + ) + cli_state.sdk.cases.update.assert_called_once_with( + 1, + name="TEST_CASE2", + status=None, + assignee=None, + description=None, + findings=None, + subject=None, + ) + + +def test_list_calls_get_all_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["cases", "list"], obj=cli_state, + ) + assert cli_state.sdk.cases.get_all.call_count == 1 + + +def test_show_calls_get_case_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["cases", "show", "1"], obj=cli_state, + ) + cli_state.sdk.cases.get.assert_called_once_with(1) + + +def test_show_with_include_file_events_calls_file_events_get_all_with_expected_params( + runner, cli_state +): + runner.invoke( + cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, + ) + cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) + + +def test_export_calls_export_summary_with_expected_params(runner, cli_state, mocker): + with mock.patch("builtins.open", mock_open()) as mf: + runner.invoke( + cli, ["cases", "export", "1"], obj=cli_state, + ) + cli_state.sdk.cases.export_summary.assert_called_once_with(1) + mf.assert_called_once_with("./1_case_summary.pdf", "wb") + + +def test_file_events_add_calls_add_event_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["cases", "file-events", "add", "1", "--event-id", "1"], obj=cli_state, + ) + cli_state.sdk.cases.file_events.add.assert_called_once_with(1, "1") + + +def test_file_events_remove_calls_delete_event_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["cases", "file-events", "remove", "1", "--event-id", "1"], obj=cli_state, + ) + cli_state.sdk.cases.file_events.delete.assert_called_once_with(1, "1") + + +def test_file_events_list_calls_get_all_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["cases", "file-events", "list", "1"], obj=cli_state, + ) + cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) + + +def test_show_when_py42_raises_exception_returns_error_message( + runner, cli_state, error +): + cli_state.sdk.cases.file_events.get_all.side_effect = Py42NotFoundError(error) + result = runner.invoke( + cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, + ) + cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) + assert "Invalid case-number 1." in result.output + + +def test_file_events_add_when_py42_raises_exception_returns_error_message( + runner, cli_state, error +): + cli_state.sdk.cases.file_events.add.side_effect = Py42BadRequestError(error) + result = runner.invoke( + cli, ["cases", "file-events", "add", "1", "--event-id", "1"], obj=cli_state, + ) + cli_state.sdk.cases.file_events.add.assert_called_once_with(1, "1") + assert "Invalid case-number or event-id." in result.output + + +def test_file_events_remove_when_py42_raises_exception_returns_error_message( + runner, cli_state, error +): + cli_state.sdk.cases.file_events.delete.side_effect = Py42NotFoundError(error) + result = runner.invoke( + cli, ["cases", "file-events", "remove", "1", "--event-id", "1"], obj=cli_state, + ) + cli_state.sdk.cases.file_events.delete.assert_called_once_with(1, "1") + assert "Invalid case-number or event-id." in result.output + + +def test_show_returns_expected_data(runner, cli_state, py42_response): + py42_response.data = json.loads(CASE_DETAILS) + cli_state.sdk.cases.get.return_value = py42_response + result = runner.invoke(cli, ["cases", "show", "1"], obj=cli_state,) + assert "test@test.test" in result.output + + +def test_list_returns_expected_data(runner, cli_state, py42_response): + py42_response.data = json.loads(ALL_CASES) + + def gen(): + yield py42_response.data + + cli_state.sdk.cases.get_all.return_value = gen() + result = runner.invoke(cli, ["cases", "list"], obj=cli_state,) + assert "test@test.test" in result.output + + +def test_show_returns_expected_data_with_include_file_events_option( + runner, cli_state, py42_response +): + py42_response.text = ALL_EVENTS + cli_state.sdk.cases.file_events.get_all.return_value = py42_response + result = runner.invoke( + cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, + ) + assert ( + "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971" + in result.output + ) + + +def test_events_list_returns_expected_data(runner, cli_state): + cli_state.sdk.cases.file_events.get_all.return_value = json.loads(ALL_EVENTS) + result = runner.invoke(cli, ["cases", "file-events", "list", "1"], obj=cli_state,) + assert ( + "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971" + in result.output + ) + + +@pytest.mark.parametrize( + "command, error_msg", + [ + ("{} create --description d".format(CASES_COMMAND), MISSING_NAME), + ("{} update --description d".format(CASES_COMMAND), MISSING_CASE_NUMBER), + ("{} show".format(CASES_COMMAND), MISSING_CASE_NUMBER), + ("{} export".format(CASES_COMMAND), MISSING_CASE_NUMBER), + ("{} add".format(CASES_FILE_EVENTS_COMMAND), MISSING_CASE_NUMBER), + ("{} add --event-id 3".format(CASES_FILE_EVENTS_COMMAND), MISSING_CASE_NUMBER), + ("{} add 3".format(CASES_FILE_EVENTS_COMMAND), MISSING_EVENT_ID), + ("{} remove 3".format(CASES_FILE_EVENTS_COMMAND), MISSING_EVENT_ID), + ("{} remove".format(CASES_FILE_EVENTS_COMMAND), MISSING_CASE_NUMBER), + ( + "{} remove --event-id 3".format(CASES_FILE_EVENTS_COMMAND), + MISSING_CASE_NUMBER, + ), + ("{} list".format(CASES_FILE_EVENTS_COMMAND), MISSING_CASE_NUMBER), + ], +) +def test_cases_command_when_missing_required_parameters_errors( + command, error_msg, runner, cli_state +): + result = runner.invoke(cli, command.split(" "), obj=cli_state) + assert result.exit_code == 2 + assert error_msg in "".join(result.output) diff --git a/tests/integration/test_cases.py b/tests/integration/test_cases.py new file mode 100644 index 000000000..b817d09c1 --- /dev/null +++ b/tests/integration/test_cases.py @@ -0,0 +1,31 @@ +import pytest +from tests.integration import run_command + +CASES_COMMAND = "code42 cases" + + +@pytest.mark.integration +@pytest.mark.parametrize( + "command", + [ + "{} list".format(CASES_COMMAND), + "{} list -f TABLE".format(CASES_COMMAND), + "{} list -f RAW-JSON".format(CASES_COMMAND), + "{} list -f JSON".format(CASES_COMMAND), + "{} list --format CSV".format(CASES_COMMAND), + "{} list --format TABLE".format(CASES_COMMAND), + "{} list --format JSON".format(CASES_COMMAND), + "{} list --format RAW-JSON".format(CASES_COMMAND), + "{} list --assignee 123".format(CASES_COMMAND), + "{} list --status OPEN".format(CASES_COMMAND), + "{} list --subject 123".format(CASES_COMMAND), + "{} list --begin-create-time 2021-01-01".format(CASES_COMMAND), + "{} list --end-create-time 2021-01-01".format(CASES_COMMAND), + "{} list --begin-update-time 2021-01-01".format(CASES_COMMAND), + "{} list --end-update-time 2021-01-01".format(CASES_COMMAND), + "{} list --name test".format(CASES_COMMAND), + ], +) +def test_alert_rules_command_returns_success_return_code(command): + return_code, response = run_command(command) + assert return_code == 0 From ed35f6188670967c02212d22694ab39f6cb0af47 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Fri, 22 Jan 2021 20:56:49 +0530 Subject: [PATCH 167/349] Added help text for hostname (#207) --- src/code42cli/cmds/alerts.py | 5 ++++- src/code42cli/cmds/auditlogs.py | 5 ++++- src/code42cli/cmds/securitydata.py | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 557155742..ca2d8c284 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -274,7 +274,10 @@ def send_to( or_query, **kwargs ): - """Send alerts to the given server address.""" + """Send alerts to the given server address. + + HOSTNAME format: address:port where port is optional and defaults to 514. + """ logger = get_logger_for_server(hostname, protocol, format) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None handlers = ext.create_send_to_handlers( diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index 38c4d9fa6..7814305bf 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -177,7 +177,10 @@ def send_to( affected_username, use_checkpoint, ): - """Send audit logs to the given server address in JSON format.""" + """Send audit logs to the given server address in JSON format. + + HOSTNAME format: address:port where port is optional and defaults to 514. + """ logger = get_logger_for_server(hostname, protocol, "RAW-JSON") cursor = _get_audit_log_cursor_store(state.profile.name) if use_checkpoint: diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 4b1d137ad..96a16581c 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -337,7 +337,10 @@ def send_to( or_query, **kwargs, ): - """Send events to the given server address.""" + """Send events to the given server address. + + HOSTNAME format: address:port where port is optional and defaults to 514. + """ logger = get_logger_for_server(hostname, protocol, format) cursor = ( _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None From 097ea2cbaa715a56a4c89a7c7e5ca6609c92d998 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 22 Jan 2021 10:49:02 -0600 Subject: [PATCH 168/349] Misc `devices` command bugfixes (#204) * fix nightly test runs * remove dupe command * stop testing python3.5 * revert * in `devices show`, don't echo "backupUsage" again when --format is JSON/RAW, since it's already contained in the first JSON output * drop CSV --format option from `devices show` since it doesn't really make sense given the data * clarify --days-since-last-connected help text a bit * add --inactive option to make it possible to select only deactivated devices * add new date option placeholders * refactor magic date parsing into a click type, and make search options more flexible * remove redundant import * use MagicDate in date options * fix option ordering * rename options * make magic time period case-insensitive * allow case-insensitive periods in regex * inadvertent commit * set UTC timezone explicitly in MagicDate result * use correct method to set UTC (not convert to UTC) * add negative tests for MagicDate, adjust timestamp regex * use .timestamp() for converting from dt > timestamp * add note to changelog * add connection/creation date filter logic, remove 'drop_most_recent' option and logic, clean up some help strings, update tests * fix JSON/RAW-JSON dataframe output format bug * fix recursion error trying to convert backup set data to json * style and dupe changelog entry * fix output format tests * remove unused imports * "the" provided value * improve error handling in _deactivate_device() * fix read_csv() * style * fix 3.8+ error about iteration over dict while modifying it * add docs and fix doc error with trailing underscore * put back --drop-most-recent as --exclude-most-recently-connected * add list-backup-sets to changelog * typo * standardize triple-quoted strings to single-quoted * wrap long help strings * simplify read_csv a bit * add more read_csv tests * style * add test for invalid guid check * use elif --- CHANGELOG.md | 3 +- docs/commands.md | 1 + docs/commands/devices.rst | 3 + src/code42cli/cmds/devices.py | 205 +++++++++++++++++--------------- src/code42cli/file_readers.py | 55 +++++---- src/code42cli/output_formats.py | 18 ++- tests/cmds/test_devices.py | 114 +++++++++++++----- tests/test_file_readers.py | 34 +++++- tests/test_output_formats.py | 12 +- 9 files changed, 284 insertions(+), 161 deletions(-) create mode 100644 docs/commands/devices.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb6c8463..70c2e8c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `devices deactivate` to deactivate a single computer. - `devices show` to retrieve detailed information about a computer. - `devices list` to retrieve info about many devices, including device settings. + - `devices list-backup-sets` to retrieve detailed info about device backup sets. - `devices bulk deactivate` to deactivate a list of devices. - `code42 departing-employee list` command. @@ -32,7 +33,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 cases file-events` commands: - `add` to add an event to a case. - `remove` to remove an event from the case. - - `list` to view all events assocaited to the case. + - `list` to view all events associated to the case. ### Changed diff --git a/docs/commands.md b/docs/commands.md index 97eeed4c1..b1a38c4b0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -6,6 +6,7 @@ * [Alerts](commands/alerts.rst) * [Alert Rules](commands/alertrules.rst) * [Departing Employee](commands/departingemployee.rst) +* [Devices](commands/devices.rst) * [High Risk Employee](commands/highriskemployee.rst) * [Legal Hold](commands/legalhold.rst) * [Cases](commands/cases.rst) diff --git a/docs/commands/devices.rst b/docs/commands/devices.rst new file mode 100644 index 000000000..d0012b9cf --- /dev/null +++ b/docs/commands/devices.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.devices:devices + :prog: devices + :show-nested: diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 00a6d6afc..61ee88ecf 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -1,16 +1,18 @@ from datetime import date -from datetime import datetime import click from pandas import concat from pandas import DataFrame from pandas import to_datetime -from pandas import to_timedelta from py42 import exceptions from py42.exceptions import Py42NotFoundError from code42cli.bulk import run_bulk_process from code42cli.click_ext.groups import OrderedGroup +from code42cli.click_ext.options import incompatible_with +from code42cli.click_ext.types import MagicDate +from code42cli.date_helper import round_datetime_to_day_end +from code42cli.date_helper import round_datetime_to_day_start from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg from code42cli.options import format_option @@ -33,8 +35,7 @@ def devices(state): required=False, is_flag=True, default=False, - help="""Prepend "deactivated_" and today's date to the name of any - deactivated devices.""", + help="Prepend 'deactivated_' to the name of any deactivated devices.", ) DATE_FORMAT = "%Y-%m-%d" @@ -43,8 +44,8 @@ def devices(state): required=False, type=click.DateTime(formats=[DATE_FORMAT]), default=None, - help="""The date on which the archive should be purged from cold storage in yyyy-MM-dd format. - If not provided, the date will be set according to the appropriate org settings.""", + help="The date on which the archive should be purged from cold storage in yyyy-MM-dd format. " + "If not provided, the date will be set according to the appropriate org settings.", ) @@ -59,8 +60,12 @@ def deactivate(state, device_guid, change_device_name, purge_date): def _deactivate_device(sdk, device_guid, change_device_name, purge_date): - device = sdk.devices.get_by_guid(device_guid) try: + int(device_guid) + except ValueError: + raise Code42CLIError("Not a valid guid.") + try: + device = sdk.devices.get_by_guid(device_guid) sdk.devices.deactivate(device.data["computerId"]) except exceptions.Py42BadRequestError: raise Code42CLIError("The device {} is in legal hold.".format(device_guid)) @@ -103,10 +108,9 @@ def _change_device_name(sdk, guid, name): @devices.command() @device_guid_argument -@format_option @sdk_options() def show(state, device_guid, format=None): - """Print device info. Requires device GUID.""" + """Print individual device info. Requires device GUID.""" formatter = OutputFormatter(format, _device_info_keys_map()) backup_set_formatter = OutputFormatter(format, _backup_set_keys_map()) @@ -145,20 +149,23 @@ def _get_device_info(sdk, device_guid): active_option = click.option( "--active", - required=False, - type=bool, is_flag=True, + help="Limits results to only active devices.", default=None, - help="Get only active or deactivated devices. Defaults to getting all devices.", ) - +inactive_option = click.option( + "--inactive", + is_flag=True, + help="Limits results to only deactivated devices.", + cls=incompatible_with("active"), +) org_uid_option = click.option( "--org-uid", required=False, type=str, default=None, - help="""Limit devices to only the ones in the org you specify. - Note that child orgs will be included.""", + help="Limit devices to only the ones in the org you specify. " + "Note that child orgs will be included.", ) include_usernames_option = click.option( @@ -171,31 +178,18 @@ def _get_device_info(sdk, device_guid): ) -@devices.command(name="list", help="Get information about many devices") +@devices.command(name="list") @active_option -@click.option( - "--days-since-last-connected", - required=False, - type=int, - help="Return only devices that have not connected in the number of days specified.", -) +@inactive_option @org_uid_option -@click.option( - "--drop-most-recent", - required=False, - type=int, - help="""Will drop the X most recently connected devices for each user from the - result list where X is the number you provide as this argument. Can be used to - avoid passing the most recently connected device for a user to the deactivate command.""", -) @click.option( "--include-backup-usage", required=False, type=bool, default=False, is_flag=True, - help="""Return backup usage information for each device - (may significantly lengthen the size of the return).""", + help="Return backup usage information for each device (may significantly lengthen the size " + "of the return).", ) @include_usernames_option @click.option( @@ -204,22 +198,59 @@ def _get_device_info(sdk, device_guid): type=bool, default=False, is_flag=True, - help="""Include device settings in output.""", + help="Include device settings in output.", +) +@click.option( + "--exclude-most-recently-connected", + type=int, + help="Filter out the N most recently connected devices per user. " + "Useful for identifying duplicate and/or replaced devices that are no longer needed across " + "an environment. If a user has 2 devices and N=1, the one device with the most recent " + "'lastConnected' date will not show up in the result list.", +) +@click.option( + "--last-connected-before", + type=MagicDate(rounding_func=round_datetime_to_day_start), + help=f"Include devices only when the 'lastConnected' field is after the provided value. {MagicDate.HELP_TEXT}", +) +@click.option( + "--last-connected-after", + type=MagicDate(rounding_func=round_datetime_to_day_end), + help="Include devices only when 'lastConnected' field is after the provided value. " + "Argument format options are the same as --last-connected-before.", +) +@click.option( + "--created-before", + type=MagicDate(rounding_func=round_datetime_to_day_start), + help="Include devices only when 'creationDate' field is less than the provided value. " + "Argument format options are the same as --last-connected-before.", +) +@click.option( + "--created-after", + type=MagicDate(rounding_func=round_datetime_to_day_end), + help="Include devices only when 'creationDate' field is greater than the provided value. " + "Argument format options are the same as --last-connected-before.", ) @format_option @sdk_options() def list_devices( state, active, - days_since_last_connected, - drop_most_recent, + inactive, org_uid, include_backup_usage, include_usernames, include_settings, + exclude_most_recently_connected, + last_connected_after, + last_connected_before, + created_after, + created_before, format, ): - """Outputs a list of all devices.""" + """Get information about many devices.""" + if inactive: + active = False columns = [ "computerId", "guid", @@ -227,30 +258,39 @@ def list_devices( "osHostname", "status", "lastConnected", + "creationDate", "productVersion", "osName", "osVersion", "userUid", ] - devices_dataframe = _get_device_dataframe( + df = _get_device_dataframe( state.sdk, columns, active, org_uid, include_backup_usage ) - if drop_most_recent: - devices_dataframe = _drop_n_devices_per_user( - devices_dataframe, drop_most_recent - ) - if days_since_last_connected: - devices_dataframe = _drop_devices_which_have_not_connected_in_some_number_of_days( - devices_dataframe, days_since_last_connected + if last_connected_after: + df = df.loc[to_datetime(df.lastConnected) > last_connected_after] + if last_connected_before: + df = df.loc[to_datetime(df.lastConnected) < last_connected_before] + if created_after: + df = df.loc[to_datetime(df.creationDate) > created_after] + if created_before: + df = df.loc[to_datetime(df.creationDate) < created_before] + if exclude_most_recently_connected: + most_recent = ( + df.sort_values(["userUid", "lastConnected"], ascending=False) + .groupby("userUid") + .head(exclude_most_recently_connected) ) + df = df.drop(most_recent.index) if include_settings: - devices_dataframe = _add_settings_to_dataframe(state.sdk, devices_dataframe) + df = _add_settings_to_dataframe(state.sdk, df) if include_usernames: - devices_dataframe = _add_usernames_to_device_dataframe( - state.sdk, devices_dataframe - ) - formatter = DataFrameOutputFormatter(format) - formatter.echo_formatted_dataframe(devices_dataframe) + df = _add_usernames_to_device_dataframe(state.sdk, df) + if df.empty: + click.echo("No results found.") + else: + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframe(df) def _get_device_dataframe( @@ -297,35 +337,6 @@ def handle_row(guid): return device_dataframe -def _drop_devices_which_have_not_connected_in_some_number_of_days( - devices_dataframe, days_since_last_connected -): - utc_now = to_datetime(datetime.utcnow(), utc=True) - devices_last_connected_dates = to_datetime( - devices_dataframe["lastConnected"], utc=True - ) - days_since_last_connected_delta = to_timedelta( - days_since_last_connected, unit="days" - ) - return devices_dataframe.loc[ - utc_now - devices_last_connected_dates > days_since_last_connected_delta, :, - ] - - -def _drop_n_devices_per_user( - device_dataframe, - number_to_drop, - sort_field="lastConnected", - sort_ascending=False, - group_field="userUid", -): - return ( - device_dataframe.sort_values(by=sort_field, ascending=sort_ascending) - .drop(device_dataframe.groupby(group_field).head(number_to_drop).index) - .reset_index(drop=True) - ) - - def _add_usernames_to_device_dataframe(sdk, device_dataframe): users_generator = sdk.users.get_all() users_list = [] @@ -337,30 +348,29 @@ def _add_usernames_to_device_dataframe(sdk, device_dataframe): return device_dataframe.merge(users_dataframe, how="left", on="userUid") -@devices.command( - name="list-backup-sets", - help="Get information about many devices and their backup sets", -) +@devices.command() @active_option +@inactive_option @org_uid_option @include_usernames_option @format_option @sdk_options() def list_backup_sets( - state, active, org_uid, include_usernames, format, + state, active, inactive, org_uid, include_usernames, format, ): - """Outputs a list of all devices.""" + """Get information about many devices and their backup sets.""" + if inactive: + active = False columns = ["guid", "userUid"] - devices_dataframe = _get_device_dataframe(state.sdk, columns, active, org_uid) + df = _get_device_dataframe(state.sdk, columns, active, org_uid) if include_usernames: - devices_dataframe = _add_usernames_to_device_dataframe( - state.sdk, devices_dataframe - ) - devices_dataframe = _add_backup_set_settings_to_dataframe( - state.sdk, devices_dataframe - ) - formatter = DataFrameOutputFormatter(format) - formatter.echo_formatted_dataframe(devices_dataframe) + df = _add_usernames_to_device_dataframe(state.sdk, df) + df = _add_backup_set_settings_to_dataframe(state.sdk, df) + if df.empty: + click.echo("No results found.") + else: + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframe(df) def _add_backup_set_settings_to_dataframe(sdk, devices_dataframe): @@ -414,17 +424,14 @@ def bulk(state): pass -@bulk.command( - name="deactivate", - help="""Deactivate all devices on the given list. - Takes as input a CSV with a 'guid' column.""", -) +@bulk.command(name="deactivate") @read_csv_arg(headers=["guid"]) @change_device_name_option @purge_date_option @format_option @sdk_options() def bulk_deactivate(state, csv_rows, change_device_name, purge_date, format): + """Deactivate all devices from the provided CSV containing a 'guid' column.""" sdk = state.sdk csv_rows[0]["deactivated"] = False formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) diff --git a/src/code42cli/file_readers.py b/src/code42cli/file_readers.py index 24bd2d2ee..5f0c9768a 100644 --- a/src/code42cli/file_readers.py +++ b/src/code42cli/file_readers.py @@ -2,6 +2,8 @@ import click +from code42cli.errors import Code42CLIError + def read_csv_arg(headers): """Helper for defining arguments that read from a csv file. Automatically converts @@ -17,31 +19,38 @@ def read_csv_arg(headers): def read_csv(file, headers): - """Helper to read a csv file object into dict rows, automatically removing header row - if it exists, and errors if column count doesn't match header list length. + """Helper to read a csv file object into a list of dict rows. + If CSV has a header row, all items in `headers` arg must be present in CSV or an + error is raised. Any extra columns will get filtered out from resulting dicts. + + If no header row is present in CSV, column count must match `headers` arg length or + else error is raised. """ - reader = csv.DictReader(file) - first_row = next(reader) - if all(header in first_row for header in headers): - return [ - {key: value for (key, value) in row.items() if key in headers} - for row in [first_row, *list(reader)] - ] - else: - file.seek(0) - reader = csv.DictReader(file, fieldnames=headers) - first_row = next(reader) - if None in first_row or None in first_row.values(): - raise click.BadParameter( - "Expected headers {} not found in {} and column count doesn't match expected size".format( - headers, file.name - ) - ) - # skip first row if it's the header values - if tuple(first_row.keys()) == tuple(first_row.values()): - return list(reader) + lines = file.readlines() + first_line = lines[0].strip().split(",") + + # handle when first row has all of our expected headers + if all(field in first_line for field in headers): + reader = csv.DictReader(lines[1:], fieldnames=first_line) + csv_rows = [{key: row[key] for key in headers} for row in reader] + if not csv_rows: + raise Code42CLIError("CSV contains no data rows.") + return csv_rows + + # handle when first row has no expected headers + elif all(field not in first_line for field in headers): + # only process header-less CSVs if we get exact expected column count + if len(first_line) == len(headers): + return list(csv.DictReader(lines, fieldnames=headers)) else: - return [first_row, *list(reader)] + raise Code42CLIError( + "CSV data is ambiguous. Column count must match expected columns exactly when no " + f"header row is present. Expected columns: {headers}" + ) + # handle when first row has some expected headers but not all + else: + missing = [field for field in headers if field not in first_line] + raise Code42CLIError(f"Missing required columns in csv: {missing}") def read_flat_file(file): diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index aeeebf5fb..81a7a3b3f 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -88,10 +88,24 @@ def __init__(self, output_format): self._format_func = DataFrame.to_csv elif output_format == OutputFormat.RAW: self._format_func = DataFrame.to_json - self._output_args.update({"orient": "records", "lines": False}) + self._output_args.update( + { + "orient": "records", + "lines": False, + "index": True, + "default_handler": str, + } + ) elif output_format == OutputFormat.JSON: self._format_func = DataFrame.to_json - self._output_args.update({"orient": "records", "lines": True}) + self._output_args.update( + { + "orient": "records", + "lines": True, + "index": True, + "default_handler": str, + } + ) def _format_output(self, output, *args, **kwargs): self._output_args.update(kwargs) diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 94e7bc30e..459751542 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -2,7 +2,6 @@ import pytest from pandas import DataFrame -from pandas import testing from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42NotFoundError @@ -13,14 +12,13 @@ from code42cli import PRODUCT_NAME from code42cli.cmds.devices import _add_backup_set_settings_to_dataframe from code42cli.cmds.devices import _add_usernames_to_device_dataframe -from code42cli.cmds.devices import ( - _drop_devices_which_have_not_connected_in_some_number_of_days, -) -from code42cli.cmds.devices import _drop_n_devices_per_user from code42cli.cmds.devices import _get_device_dataframe from code42cli.main import cli _NAMESPACE = "{}.cmds.devices".format(PRODUCT_NAME) +TEST_DATE_OLDER = "2020-01-01T12:00:00.774Z" +TEST_DATE_NEWER = "2021-01-01T12:00:00.774Z" +TEST_DATE_MIDDLE = "2020-06-01T12:00:00" TEST_DEVICE_GUID = "954143368874689941" TEST_DEVICE_ID = 139527 TEST_ARCHIVE_GUID = "954143426849296547" @@ -104,15 +102,15 @@ "blocked": False, "alertState": 2, "alertStates": ["CriticalConnectionAlert"], - "userId": 1014, - "userUid": "836473273124890369", + "userId": 1320, + "userUid": "840103986007089121", "orgId": 1017, "orgUid": "836473214639515393", "computerExtRef": None, "notes": None, "parentComputerId": None, "parentComputerGuid": None, - "lastConnected": "2018-03-16T17:06:50.774Z", + "lastConnected": TEST_DATE_OLDER, "osName": "linux", "osVersion": "4.4.0-96-generic", "osArch": "amd64", @@ -124,7 +122,7 @@ "version": 1512021600671, "productVersion": "6.7.1", "buildVersion": 4589, - "creationDate": "2018-03-16T16:20:00.871Z", + "creationDate": TEST_DATE_OLDER, "modificationDate": "2020-09-03T13:32:02.383Z", "loginDate": "2018-03-16T16:52:18.900Z", "service": "CrashPlan", @@ -148,7 +146,7 @@ "notes": None, "parentComputerId": None, "parentComputerGuid": None, - "lastConnected": "2018-03-19T20:04:02.999Z", + "lastConnected": TEST_DATE_NEWER, "osName": "win", "osVersion": "6.1", "osArch": "amd64", @@ -160,7 +158,7 @@ "version": 1508734800652, "productVersion": "6.5.2", "buildVersion": 32, - "creationDate": "2018-03-19T19:43:16.918Z", + "creationDate": TEST_DATE_NEWER, "modificationDate": "2020-09-08T15:43:45.875Z", "loginDate": "2018-03-19T20:03:45.360Z", "service": "CrashPlan", @@ -351,6 +349,13 @@ def test_deactivate_deactivates_device( cli_state.sdk.devices.deactivate.assert_called_once_with(TEST_DEVICE_ID) +def test_deactivate_when_given_non_guid_raises_before_making_request(runner, cli_state): + result = runner.invoke(cli, ["devices", "deactivate", "not_a_guid"], obj=cli_state) + assert result.exit_code == 1 + assert "Not a valid guid." in result.output + assert cli_state.sdk.devices.deactivate.call_count == 0 + + def test_deactivate_when_given_flag_updates_purge_date( runner, cli_state, @@ -460,6 +465,7 @@ def test_get_device_dataframe_returns_correct_columns( "osHostname", "status", "lastConnected", + "creationDate", "productVersion", "osName", "osVersion", @@ -473,6 +479,7 @@ def test_get_device_dataframe_returns_correct_columns( assert "guid" in result.columns assert "status" in result.columns assert "lastConnected" in result.columns + assert "creationDate" in result.columns assert "productVersion" in result.columns assert "osName" in result.columns assert "osVersion" in result.columns @@ -488,19 +495,6 @@ def test_device_dataframe_return_includes_backupusage_when_flag_passed( assert "backupUsage" in result.columns -def test_drop_n_devices_per_user_drops_correct_devices(): - testdf = DataFrame.from_records( - [ - {"userUid": 0, "lastConnected": 0}, - {"userUid": 0, "lastConnected": 1}, - {"userUid": 1, "lastConnected": 0}, - ] - ) - expected_return = DataFrame.from_records([{"userUid": 0, "lastConnected": 1}]) - returndf = _drop_n_devices_per_user(testdf, 1) - testing.assert_frame_equal(expected_return, returndf) - - def test_add_usernames_to_device_dataframe_adds_usernames_to_dataframe( cli_state, get_all_users_success ): @@ -511,16 +505,70 @@ def test_add_usernames_to_device_dataframe_adds_usernames_to_dataframe( assert "username" in result.columns -def test_drop_devices_which_have_not_connected_in_some_number_of_days_drops_appropriate_devices(): - testdf = DataFrame.from_records( - [ - {"lastConnected": "2019-01-09T17:09:26.432Z"}, - {"lastConnected": date.today().isoformat() + "T17:09:26.432Z"}, - ] +def test_last_connected_after_filters_appropriate_results( + cli_state, runner, get_all_devices_success +): + result = runner.invoke( + cli, + ["devices", "list", "--last-connected-after", TEST_DATE_MIDDLE], + obj=cli_state, + ) + assert TEST_DATE_NEWER in result.output + assert TEST_DATE_OLDER not in result.output + + +def test_last_connected_before_filters_appropriate_results( + cli_state, runner, get_all_devices_success +): + result = runner.invoke( + cli, + ["devices", "list", "--last-connected-before", TEST_DATE_MIDDLE], + obj=cli_state, + ) + assert TEST_DATE_NEWER not in result.output + assert TEST_DATE_OLDER in result.output + + +def test_created_after_filters_appropriate_results( + cli_state, runner, get_all_devices_success +): + result = runner.invoke( + cli, ["devices", "list", "--created-after", TEST_DATE_MIDDLE], obj=cli_state, + ) + assert TEST_DATE_NEWER in result.output + assert TEST_DATE_OLDER not in result.output + + +def test_created_before_filters_appropriate_results( + cli_state, runner, get_all_devices_success +): + result = runner.invoke( + cli, ["devices", "list", "--created-before", TEST_DATE_MIDDLE], obj=cli_state, + ) + assert TEST_DATE_NEWER not in result.output + assert TEST_DATE_OLDER in result.output + + +def test_exclude_most_recent_connected_filters_appropriate_results( + cli_state, runner, get_all_devices_success +): + older_connection_guid = TEST_COMPUTER_PAGE["computers"][0]["guid"] + newer_connection_guid = TEST_COMPUTER_PAGE["computers"][1]["guid"] + result_1 = runner.invoke( + cli, + ["devices", "list", "--exclude-most-recently-connected", "1"], + obj=cli_state, + ) + assert older_connection_guid in result_1.output + assert newer_connection_guid not in result_1.output + + result_2 = runner.invoke( + cli, + ["devices", "list", "--exclude-most-recently-connected", "2"], + obj=cli_state, ) - result = _drop_devices_which_have_not_connected_in_some_number_of_days(testdf, 30) - assert "2019-01-09T17:09:26.432Z" in result.values - assert date.today().isoformat() + "T17:09:26.432Z" not in result.values + assert older_connection_guid not in result_2.output + assert newer_connection_guid not in result_2.output def test_add_backup_set_settings_to_dataframe_returns_one_line_per_backup_set( diff --git a/tests/test_file_readers.py b/tests/test_file_readers.py index 3b08159e0..627f15aeb 100644 --- a/tests/test_file_readers.py +++ b/tests/test_file_readers.py @@ -1,3 +1,6 @@ +import pytest + +from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv HEADERLESS_CSV = [ @@ -22,7 +25,7 @@ def test_read_csv_handles_headerless_columns_in_proper_number_and_order(runner): assert result_list[1]["header3"] == "col3_val2" -def test_read_scv_handles_headered_columns_in_arbitrary_number_and_order(runner): +def test_read_csv_handles_headered_columns_in_arbitrary_number_and_order(runner): with runner.isolated_filesystem(): with open("test_csv.csv", "w") as csv: csv.writelines(HEADERED_CSV) @@ -30,3 +33,32 @@ def test_read_scv_handles_headered_columns_in_arbitrary_number_and_order(runner) result_list = read_csv(file=csv, headers=HEADERS) assert result_list[0]["header1"] == "col1_val1" assert result_list[1]["header3"] == "col3_val2" + + +def test_read_csv_raises_when_no_header_detected_and_column_count_doesnt_match_expected_header( + runner, +): + with runner.isolated_filesystem(): + with open("test_csv.csv", "w") as csv: + csv.writelines(HEADERLESS_CSV) + with open("test_csv.csv") as csv: + with pytest.raises(Code42CLIError): + read_csv(csv, ["column1", "column2"]) + + +def test_read_csv_when_all_expected_headers_present_filters_out_extra_columns(runner): + with runner.isolated_filesystem(): + with open("test_csv.csv", "w") as csv: + csv.writelines(HEADERED_CSV) + with open("test_csv.csv") as csv: + result_list = read_csv(file=csv, headers=HEADERS) + assert "extra_column" not in result_list[0] + + +def test_read_csv_when_some_but_not_all_required_headers_present_raises(runner): + with runner.isolated_filesystem(): + with open("test_csv.csv", "w") as csv: + csv.writelines(HEADERED_CSV) + with open("test_csv.csv") as csv: + with pytest.raises(Code42CLIError): + read_csv(file=csv, headers=HEADERS + ["extra_header"]) diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index 9c824f750..4e8f7380d 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -202,7 +202,11 @@ def test_init_sets_format_func_to_formatted_json_function_when_json_format_optio formatter = output_formats_module.DataFrameOutputFormatter(output_format) formatter.echo_formatted_dataframe(TEST_DATAFRAME) mock_dataframe_to_json.assert_called_once_with( - TEST_DATAFRAME, orient="records", lines=False, index=False + TEST_DATAFRAME, + orient="records", + lines=False, + index=True, + default_handler=str, ) def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_passed( @@ -212,7 +216,11 @@ def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_p formatter = output_formats_module.DataFrameOutputFormatter(output_format) formatter.echo_formatted_dataframe(TEST_DATAFRAME) mock_dataframe_to_json.assert_called_once_with( - TEST_DATAFRAME, orient="records", lines=True, index=False + TEST_DATAFRAME, + orient="records", + lines=True, + index=True, + default_handler=str, ) def test_init_sets_format_func_to_table_function_when_table_format_option_is_passed( From 176388ce2f2253e9f2c9526cfb11f0c71f9098fd Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 22 Jan 2021 11:35:44 -0600 Subject: [PATCH 169/349] rm extra spaces (#208) --- src/code42cli/cmds/devices.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 61ee88ecf..1ab4aa4ea 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -116,8 +116,10 @@ def show(state, device_guid, format=None): backup_set_formatter = OutputFormatter(format, _backup_set_keys_map()) device_info = _get_device_info(state.sdk, device_guid) formatter.echo_formatted_list([device_info]) - click.echo() - backup_set_formatter.echo_formatted_list(device_info["backupUsage"]) + backup_usage = device_info.get("backupUsage") + if backup_usage: + click.echo() + backup_set_formatter.echo_formatted_list(backup_usage) def _device_info_keys_map(): From 1eb69c17767667b17d9bc6bb8adf0de13581404a Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 22 Jan 2021 13:11:10 -0600 Subject: [PATCH 170/349] Only use pager for large amounts when echoing dfs (#210) --- src/code42cli/output_formats.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 81a7a3b3f..90a8638b1 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -112,7 +112,11 @@ def _format_output(self, output, *args, **kwargs): return self._format_func(output, *args, **self._output_args) def echo_formatted_dataframe(self, output, *args, **kwargs): - click.echo_via_pager(self._format_output(output, *args, **kwargs)) + str_output = self._format_output(output, *args, **kwargs) + if len(output) <= 10: + click.echo(str_output) + else: + click.echo_via_pager(str_output) def to_csv(output): From 071831e9860d3260bb84bfbc589bde7537390033 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 22 Jan 2021 13:11:36 -0600 Subject: [PATCH 171/349] Use temp profile context when executing integration tests (#206) --- tests/cmds/conftest.py | 2 +- tests/integration/__init__.py | 36 -------------- tests/integration/conftest.py | 70 +++++++++++++++++++++++++++ tests/integration/test_alert_rules.py | 5 +- tests/integration/test_alerts.py | 39 ++++++++------- tests/integration/test_auditlogs.py | 5 +- tests/integration/test_legal_hold.py | 5 +- 7 files changed, 98 insertions(+), 64 deletions(-) create mode 100644 tests/integration/conftest.py diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index bd0a1e39a..98e26c7f3 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -35,7 +35,7 @@ def sdk(mocker): return mocker.MagicMock(spec=SDKClient) -@pytest.fixture() +@pytest.fixture def mock_42(mocker): return mocker.patch("py42.sdk.from_local_account") diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 6b5f4664b..e69de29bb 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,36 +0,0 @@ -import os - -import pexpect - - -LINE_FEED = b"\r\n" -PASSWORD_PROMPT = b"Password: " -ENCODING_TYPE = "utf-8" - - -def encode_response(line, encoding_type=ENCODING_TYPE): - return line.decode(encoding_type) - - -def run_command(command): - - process = pexpect.spawn(command) - response = [] - try: - expected = process.expect([PASSWORD_PROMPT, pexpect.EOF]) - if expected == 0: - process.sendline(os.environ["C42_PW"]) - process.expect(LINE_FEED) - output = process.readlines() - response = [encode_response(line) for line in output] - else: - output = process.before - response = encode_response(output).splitlines() - except pexpect.TIMEOUT: - process.close() - return process.exitstatus, response - process.close() - return process.exitstatus, response - - -__all__ = [run_command] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 000000000..bea8da11b --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,70 @@ +import os +from contextlib import contextmanager + +import pexpect +import pytest + +from code42cli.errors import Code42CLIError +from code42cli.profile import create_profile +from code42cli.profile import delete_profile +from code42cli.profile import get_profile +from code42cli.profile import switch_default_profile + +TEST_PROFILE_NAME = "TEMP-INTEGRATION-TEST" +_LINE_FEED = b"\r\n" +_PASSWORD_PROMPT = b"Password: " +_ENCODING_TYPE = "utf-8" + + +@contextmanager +def use_temp_profile(): + """Creates a temporary profile to use for executing integration tests.""" + host = os.environ.get("C42_HOST") or "http://127.0.0.1:4200" + username = os.environ.get("C42_USER") or "test_username@example.com" + password = os.environ.get("C42_PW") or "test_password" + current_profile_name = _get_current_profile_name() + create_profile(TEST_PROFILE_NAME, host, username, True) + switch_default_profile(TEST_PROFILE_NAME) + yield password + delete_profile(TEST_PROFILE_NAME) + + # Switch back to the original profile if there was one + if current_profile_name: + switch_default_profile(current_profile_name) + + +def _get_current_profile_name(): + try: + profile = get_profile() + return profile.name + except Code42CLIError: + return None + + +@pytest.fixture +def command_runner(): + def run_command(command): + with use_temp_profile() as pw: + process = pexpect.spawn(command) + response = [] + try: + expected = process.expect([_PASSWORD_PROMPT, pexpect.EOF]) + if expected == 0: + process.sendline(pw) + process.expect(_LINE_FEED) + output = process.readlines() + response = [_encode_response(line) for line in output] + else: + output = process.before + response = _encode_response(output).splitlines() + except pexpect.TIMEOUT: + process.close() + return process.exitstatus, response + process.close() + return process.exitstatus, response + + return run_command + + +def _encode_response(line, encoding_type=_ENCODING_TYPE): + return line.decode(encoding_type) diff --git a/tests/integration/test_alert_rules.py b/tests/integration/test_alert_rules.py index 3e38b784a..4d174b094 100644 --- a/tests/integration/test_alert_rules.py +++ b/tests/integration/test_alert_rules.py @@ -1,5 +1,4 @@ import pytest -from tests.integration import run_command ALERT_RULES_COMMAND = "code42 alert-rules" @@ -20,6 +19,6 @@ "{} list --format RAW-JSON".format(ALERT_RULES_COMMAND), ], ) -def test_alert_rules_command_returns_success_return_code(command): - return_code, response = run_command(command) +def test_alert_rules_command_returns_success_return_code(command, command_runner): + return_code, response = command_runner(command) assert return_code == 0 diff --git a/tests/integration/test_alerts.py b/tests/integration/test_alerts.py index 59ec3d1aa..04fa99915 100644 --- a/tests/integration/test_alerts.py +++ b/tests/integration/test_alerts.py @@ -2,7 +2,6 @@ from datetime import timedelta import pytest -from tests.integration import run_command begin_date = datetime.utcnow() - timedelta(days=20) @@ -10,7 +9,9 @@ begin_date_str = begin_date.strftime("%Y-%m-%d") end_date_str = end_date.strftime("%Y-%m-%d") -ALERT_COMMAND = "code42 alerts search -b {} -e {}".format(begin_date_str, end_date_str) +ALERT_SEARCH_COMMAND = "code42 alerts search -b {} -e {}".format( + begin_date_str, end_date_str +) ADVANCED_QUERY = """{"groupClause":"AND", "groups":[{"filterClause":"AND", "filters":[{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"2020-09-13T00:00:00.000Z"}, {"operator":"ON_OR_BEFORE", "term":"eventTimestamp", "value":"2020-12-07T13:20:15.195Z"}]}], @@ -25,25 +26,27 @@ @pytest.mark.parametrize( "command", [ - ALERT_COMMAND, - "{} --state OPEN".format(ALERT_COMMAND), - "{} --state RESOLVED".format(ALERT_COMMAND), - "{} --actor user@code42.com".format(ALERT_COMMAND), - "{} --rule-name 'File Upload Alert'".format(ALERT_COMMAND), - "{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format(ALERT_COMMAND), - "{} --rule-type FedEndpointExfiltration".format(ALERT_COMMAND), - "{} --description 'Alert on any file upload'".format(ALERT_COMMAND), - "{} --exclude-rule-type 'FedEndpointExfiltration'".format(ALERT_COMMAND), + ALERT_SEARCH_COMMAND, + "{} --state OPEN".format(ALERT_SEARCH_COMMAND), + "{} --state RESOLVED".format(ALERT_SEARCH_COMMAND), + "{} --actor user@code42.com".format(ALERT_SEARCH_COMMAND), + "{} --rule-name 'File Upload Alert'".format(ALERT_SEARCH_COMMAND), + "{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format( + ALERT_SEARCH_COMMAND + ), + "{} --rule-type FedEndpointExfiltration".format(ALERT_SEARCH_COMMAND), + "{} --description 'Alert on any file upload'".format(ALERT_SEARCH_COMMAND), + "{} --exclude-rule-type 'FedEndpointExfiltration'".format(ALERT_SEARCH_COMMAND), "{} --exclude-rule-id '962a6a1c-54f6-4477-90bd-a08cc74cbf71'".format( - ALERT_COMMAND + ALERT_SEARCH_COMMAND ), - "{} --exclude-rule-name 'File Upload Alert'".format(ALERT_COMMAND), - "{} --exclude-actor-contains 'user@code42.com'".format(ALERT_COMMAND), - "{} --exclude-actor 'user@code42.com'".format(ALERT_COMMAND), - "{} --actor-contains 'user@code42.com'".format(ALERT_COMMAND), + "{} --exclude-rule-name 'File Upload Alert'".format(ALERT_SEARCH_COMMAND), + "{} --exclude-actor-contains 'user@code42.com'".format(ALERT_SEARCH_COMMAND), + "{} --exclude-actor 'user@code42.com'".format(ALERT_SEARCH_COMMAND), + "{} --actor-contains 'user@code42.com'".format(ALERT_SEARCH_COMMAND), ALERT_ADVANCED_QUERY_COMMAND, ], ) -def test_alert_command_returns_success_return_code(command): - return_code, response = run_command(command) +def test_alert_command_returns_success_return_code(command, command_runner): + return_code, response = command_runner(command) assert return_code == 0 diff --git a/tests/integration/test_auditlogs.py b/tests/integration/test_auditlogs.py index 382784f9c..921aa2dc2 100644 --- a/tests/integration/test_auditlogs.py +++ b/tests/integration/test_auditlogs.py @@ -2,7 +2,6 @@ from datetime import timedelta import pytest -from tests.integration import run_command SEARCH_COMMAND = "code42 audit-logs search" BASE_COMMAND = "{} -b".format(SEARCH_COMMAND) @@ -42,6 +41,6 @@ ("{} '{}' --debug".format(BASE_COMMAND, begin_date_str)), ], ) -def test_auditlogs_search_command_returns_success_return_code(command): - return_code, response = run_command(command) +def test_auditlogs_search_command_returns_success_return_code(command, command_runner): + return_code, response = command_runner(command) assert return_code == 0 diff --git a/tests/integration/test_legal_hold.py b/tests/integration/test_legal_hold.py index f7af3cb18..ac32826aa 100644 --- a/tests/integration/test_legal_hold.py +++ b/tests/integration/test_legal_hold.py @@ -1,5 +1,4 @@ import pytest -from tests.integration import run_command LEGAL_HOLD_COMMAND = "code42 legal-hold" @@ -20,6 +19,6 @@ "{} list --format RAW-JSON".format(LEGAL_HOLD_COMMAND), ], ) -def test_alert_rules_command_returns_success_return_code(command): - return_code, response = run_command(command) +def test_alert_rules_command_returns_success_return_code(command, command_runner): + return_code, response = command_runner(command) assert return_code == 0 From 66be4e2e276c07464aabe903173c2135567d3d76 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 22 Jan 2021 15:07:18 -0600 Subject: [PATCH 172/349] Reactivate (#209) --- CHANGELOG.md | 6 ++- src/code42cli/bulk.py | 2 +- src/code42cli/cmds/devices.py | 76 ++++++++++++++++++++++++++++++----- tests/cmds/test_devices.py | 63 +++++++++++++++++++++++++++-- 4 files changed, 129 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c2e8c44..d4bd48b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta # Added - The `devices` command is added. Included are: - - `devices deactivate` to deactivate a single computer. - - `devices show` to retrieve detailed information about a computer. + - `devices deactivate` to deactivate a single device. + - `devices reactivate` to reactivate a single device. + - `devices show` to retrieve detailed information about a device. - `devices list` to retrieve info about many devices, including device settings. - `devices list-backup-sets` to retrieve detailed info about device backup sets. - `devices bulk deactivate` to deactivate a list of devices. + - `devices bulk reactivate` to reactivate a list of devices. - `code42 departing-employee list` command. diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 2f57b87c0..85b0bebf9 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -74,6 +74,7 @@ def run_bulk_process(row_handler, rows, progress_label=None): row_handler (callable): A callable that you define to process values from the row as either *args or **kwargs. rows (iterable): the rows to process. + progress_label: a label that prints with the progress bar. """ processor = _create_bulk_processor(row_handler, rows, progress_label) return processor.run() @@ -93,7 +94,6 @@ class BulkProcessor: and first row `1,test`, then `row_handler` should receive kwargs `prop_a: '1', prop_b: 'test'` when processing the first row. If it's a flat file, then `row_handler` only needs to take an extra arg. - reader (CSVReader or FlatFileReader): A generator that reads rows and yields data into `row_handler`. """ def __init__(self, row_handler, rows, worker=None, progress_label=None): diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 1ab4aa4ea..520524dd9 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -28,7 +28,9 @@ def devices(state): pass -device_guid_argument = click.argument("device-guid", type=str) +device_guid_argument = click.argument( + "device-guid", type=str, callback=lambda ctx, param, arg: _verify_guid_type(arg), +) change_device_name_option = click.option( "--change-device-name", @@ -59,20 +61,19 @@ def deactivate(state, device_guid, change_device_name, purge_date): _deactivate_device(state.sdk, device_guid, change_device_name, purge_date) +@devices.command() +@device_guid_argument +@sdk_options() +def reactivate(state, device_guid): + """Reactivate a device within Code42. Requires the device GUID to reactivate.""" + _reactivate_device(state.sdk, device_guid) + + def _deactivate_device(sdk, device_guid, change_device_name, purge_date): try: - int(device_guid) - except ValueError: - raise Code42CLIError("Not a valid guid.") - try: - device = sdk.devices.get_by_guid(device_guid) - sdk.devices.deactivate(device.data["computerId"]) + device = _change_device_activation(sdk, device_guid, "deactivate") except exceptions.Py42BadRequestError: raise Code42CLIError("The device {} is in legal hold.".format(device_guid)) - except exceptions.Py42NotFoundError: - raise Code42CLIError("The device {} was not found.".format(device_guid)) - except exceptions.Py42ForbiddenError: - raise Code42CLIError("Unable to deactivate {}.".format(device_guid)) if purge_date: _update_cold_storage_purge_date(sdk, device_guid, purge_date) if change_device_name and not device.data["name"].startswith("deactivated_"): @@ -86,6 +87,35 @@ def _deactivate_device(sdk, device_guid, change_device_name, purge_date): ) +def _reactivate_device(sdk, device_guid): + _change_device_activation(sdk, device_guid, "reactivate") + + +def _change_device_activation(sdk, device_guid, cmd_str): + try: + device = sdk.devices.get_by_guid(device_guid) + device_id = device.data["computerId"] + if cmd_str == "reactivate": + sdk.devices.reactivate(device_id) + elif cmd_str == "deactivate": + sdk.devices.deactivate(device_id) + return device + except exceptions.Py42NotFoundError: + raise Code42CLIError("The device {} was not found.".format(device_guid)) + except exceptions.Py42ForbiddenError: + raise Code42CLIError("Unable to {} {}.".format(cmd_str, device_guid)) + + +def _verify_guid_type(device_guid): + if device_guid is None: + return + try: + int(device_guid) + return device_guid + except ValueError: + raise Code42CLIError("Not a valid guid.") + + def _update_cold_storage_purge_date(sdk, guid, purge_date): archives_response = sdk.archive.get_all_by_device_guid(guid) archive_guid_list = [ @@ -455,3 +485,27 @@ def handle_row(**row): handle_row, csv_rows, progress_label="Deactivating devices:" ) formatter.echo_formatted_list(result_rows) + + +@bulk.command(name="reactivate") +@read_csv_arg(headers=["guid"]) +@format_option +@sdk_options() +def bulk_reactivate(state, csv_rows, format): + """Reactivate all devices from the provided CSV containing a 'guid' column.""" + sdk = state.sdk + csv_rows[0]["reactivated"] = False + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + + def handle_row(**row): + try: + _reactivate_device(sdk, row["guid"]) + row["reactivated"] = "True" + except Exception as e: + row["reactivated"] = "False: {}".format(e) + return row + + result_rows = run_bulk_process( + handle_row, csv_rows, progress_label="Reactivating devices:" + ) + formatter.echo_formatted_list(result_rows) diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 459751542..3cbb11850 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -254,7 +254,7 @@ def mock_backup_set(mocker): @pytest.fixture -def deactivate_response(mocker): +def empty_successful_response(mocker): return _create_py42_response(mocker, "") @@ -303,8 +303,13 @@ def archives_list_success(cli_state): @pytest.fixture -def deactivate_device_success(cli_state, deactivate_response): - cli_state.sdk.devices.deactivate.return_value = deactivate_response +def deactivate_device_success(cli_state, empty_successful_response): + cli_state.sdk.devices.deactivate.return_value = empty_successful_response + + +@pytest.fixture +def reactivate_device_success(cli_state, empty_successful_response): + cli_state.sdk.devices.reactivate.return_value = empty_successful_response @pytest.fixture @@ -312,6 +317,11 @@ def deactivate_device_not_found_failure(cli_state): cli_state.sdk.devices.deactivate.side_effect = Py42NotFoundError(HTTPError()) +@pytest.fixture +def reactivate_device_not_found_failure(cli_state): + cli_state.sdk.devices.reactivate.side_effect = Py42NotFoundError(HTTPError()) + + @pytest.fixture def deactivate_device_in_legal_hold_failure(cli_state): cli_state.sdk.devices.deactivate.side_effect = Py42BadRequestError(HTTPError()) @@ -322,6 +332,11 @@ def deactivate_device_not_allowed_failure(cli_state): cli_state.sdk.devices.deactivate.side_effect = Py42ForbiddenError(HTTPError()) +@pytest.fixture +def reactivate_device_not_allowed_failure(cli_state): + cli_state.sdk.devices.reactivate.side_effect = Py42ForbiddenError(HTTPError()) + + @pytest.fixture def backupusage_success(cli_state, backupusage_response): cli_state.sdk.devices.get_by_guid.return_value = backupusage_response @@ -440,6 +455,33 @@ def test_deactivate_fails_if_device_deactivation_forbidden( assert "Unable to deactivate {}.".format(TEST_DEVICE_GUID) in result.output +def test_reactivate_reactivates_device( + runner, cli_state, deactivate_device_success, get_device_by_guid_success +): + runner.invoke(cli, ["devices", "reactivate", TEST_DEVICE_GUID], obj=cli_state) + cli_state.sdk.devices.reactivate.assert_called_once_with(TEST_DEVICE_ID) + + +def test_reactivate_fails_if_device_does_not_exist( + runner, cli_state, reactivate_device_not_found_failure +): + result = runner.invoke( + cli, ["devices", "reactivate", TEST_DEVICE_GUID], obj=cli_state + ) + assert result.exit_code == 1 + assert f"The device {TEST_DEVICE_GUID} was not found." in result.output + + +def test_reactivate_fails_if_device_reactivation_forbidden( + runner, cli_state, reactivate_device_not_allowed_failure +): + result = runner.invoke( + cli, ["devices", "reactivate", TEST_DEVICE_GUID], obj=cli_state + ) + assert result.exit_code == 1 + assert f"Unable to reactivate {TEST_DEVICE_GUID}." in result.output + + def test_show_prints_device_info(runner, cli_state, backupusage_success): result = runner.invoke(cli, ["devices", "show", TEST_DEVICE_GUID], obj=cli_state) assert "SNWINTEST1" in result.output @@ -581,7 +623,7 @@ def test_add_backup_set_settings_to_dataframe_returns_one_line_per_backup_set( def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state): - bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_bulk_deactivate.csv", "w") as csv: csv.writelines(["guid,username\n", "test,value\n"]) @@ -598,3 +640,16 @@ def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state): "purge_date": None, } ] + + +def test_bulk_reactivate_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_reactivate.csv", "w") as csv: + csv.writelines(["guid,username\n", "test,value\n"]) + runner.invoke( + cli, + ["devices", "bulk", "reactivate", "test_bulk_reactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [{"guid": "test", "reactivated": False}] From 67e21d5ab25587789fb9e8700b1944f93218449b Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 25 Jan 2021 11:13:32 -0600 Subject: [PATCH 173/349] Gen template devices (#213) --- CHANGELOG.md | 1 + src/code42cli/bulk.py | 11 +++++----- src/code42cli/cmds/alert_rules.py | 2 +- src/code42cli/cmds/devices.py | 19 ++++++++++++++-- tests/test_bulk.py | 36 +++++++++++++++++++++++++++++++ 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4bd48b5e..ff73b7452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `devices list-backup-sets` to retrieve detailed info about device backup sets. - `devices bulk deactivate` to deactivate a list of devices. - `devices bulk reactivate` to reactivate a list of devices. + - `devices bulk generate-template` to create a blank CSV file for bulk commands. - `code42 departing-employee list` command. diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 85b0bebf9..a87c3ebb8 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -29,7 +29,7 @@ def write_template_file(path, columns=None, flat_item=None): ) -def generate_template_cmd_factory(group_name, commands_dict): +def generate_template_cmd_factory(group_name, commands_dict, help_message=None): """Helper function that creates a `generate-template` click command that can be added to `bulk` sub-command groups. @@ -42,8 +42,12 @@ def generate_template_cmd_factory(group_name, commands_dict): If a cmd takes a flat file, value should be a string indicating what item the flat file rows should contain. """ + help_message = ( + help_message + or "Generate the CSV template needed for bulk adding/removing users." + ) - @click.command() + @click.command(help=help_message) @click.argument("cmd", type=click.Choice(list(commands_dict))) @click.option( "--path", @@ -52,9 +56,6 @@ def generate_template_cmd_factory(group_name, commands_dict): help="Write template file to specific file path/name.", ) def generate_template(cmd, path): - """\b - Generate the CSV template needed for bulk adding/removing users. - """ columns = commands_dict[cmd] if not path: filename = "{}_bulk_{}.csv".format(group_name, cmd.replace("-", "_")) diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index de05b58d2..101a5eb2d 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -117,7 +117,7 @@ def bulk(state): @bulk.command( - help="Bulk add users to alert rules from a csv file. CSV file format: {}".format( + help="Bulk add users to alert rules from a CSV file. CSV file format: {}".format( ",".join(ALERT_RULES_CSV_HEADERS) ) ) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 520524dd9..312be7db9 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -7,6 +7,7 @@ from py42 import exceptions from py42.exceptions import Py42NotFoundError +from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process from code42cli.click_ext.groups import OrderedGroup from code42cli.click_ext.options import incompatible_with @@ -456,8 +457,22 @@ def bulk(state): pass +_bulk_device_activation_headers = ["guid"] + + +devices_generate_template = generate_template_cmd_factory( + group_name="devices", + commands_dict={ + "reactivate": _bulk_device_activation_headers, + "deactivate": _bulk_device_activation_headers, + }, + help_message="Generate the CSV template needed for bulk device commands.", +) +bulk.add_command(devices_generate_template) + + @bulk.command(name="deactivate") -@read_csv_arg(headers=["guid"]) +@read_csv_arg(headers=_bulk_device_activation_headers) @change_device_name_option @purge_date_option @format_option @@ -488,7 +503,7 @@ def handle_row(**row): @bulk.command(name="reactivate") -@read_csv_arg(headers=["guid"]) +@read_csv_arg(headers=_bulk_device_activation_headers) @format_option @sdk_options() def bulk_reactivate(state, csv_rows, format): diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 90ef1539c..6f7c56281 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -5,6 +5,7 @@ from code42cli import errors from code42cli import PRODUCT_NAME from code42cli.bulk import BulkProcessor +from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process from code42cli.logger import get_view_error_details_message @@ -31,6 +32,41 @@ def func_with_one_arg(sdk, profile, test1): pass +def test_generate_template_cmd_factory_returns_expected_command(): + add_headers = ["foo", "bar"] + remove_headers = ["test"] + help_message = "HELP!" + template = generate_template_cmd_factory( + group_name="cmd-group", + commands_dict={"add": add_headers, "remove": remove_headers}, + help_message=help_message, + ) + assert template.help == help_message + assert template.name == "generate-template" + assert len(template.params) == 2 + assert template.params[0].name == "cmd" + assert template.params[0].type.choices == ["add", "remove"] + assert template.params[1].name == "path" + + +def test_generate_template_cmd_factory_when_using_defaults_returns_expected_command(): + add_headers = ["foo", "bar"] + remove_headers = ["test"] + template = generate_template_cmd_factory( + group_name="cmd-group", + commands_dict={"add": add_headers, "remove": remove_headers}, + ) + assert ( + template.help + == "Generate the CSV template needed for bulk adding/removing users." + ) + assert template.name == "generate-template" + assert len(template.params) == 2 + assert template.params[0].name == "cmd" + assert template.params[0].type.choices == ["add", "remove"] + assert template.params[1].name == "path" + + def test_run_bulk_process_calls_run(bulk_processor, bulk_processor_factory): errors.ERRORED = False run_bulk_process(func_with_one_arg, None) From 5d6f8dcf44069f6358b7186ad9f545bbe0982368 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 25 Jan 2021 12:00:21 -0600 Subject: [PATCH 174/349] Bumps (#214) --- CHANGELOG.md | 12 ++++++------ src/code42cli/__version__.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff73b7452..46178e776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -# Unreleased +## 1.2.0 - 2021-01-25 -# Added +### Added - The `devices` command is added. Included are: - `devices deactivate` to deactivate a single device. @@ -29,14 +29,14 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 cases` commands: - `create` to create a new case. - `update` to update case details. - - `export` to download case summary in a pdf file. - - `list` to view all the cases. - - `show` to view details of a particular case. + - `export` to download a case summary as a PDF file. + - `list` to view all cases. + - `show` to view the details of a particular case. - `code42 cases file-events` commands: - `add` to add an event to a case. - `remove` to remove an event from the case. - - `list` to view all events associated to the case. + - `list` to view all events associated with a case. ### Changed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 6849410aa..c68196d1c 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.2.0" From 57262dca45da3ce55199e12ab13927b65570b600 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 25 Jan 2021 12:32:59 -0600 Subject: [PATCH 175/349] Improv err (#215) --- src/code42cli/cmds/devices.py | 12 +++++++++--- tests/cmds/test_devices.py | 22 +++++++++++++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 312be7db9..9c6aad519 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -74,7 +74,9 @@ def _deactivate_device(sdk, device_guid, change_device_name, purge_date): try: device = _change_device_activation(sdk, device_guid, "deactivate") except exceptions.Py42BadRequestError: - raise Code42CLIError("The device {} is in legal hold.".format(device_guid)) + raise Code42CLIError( + "The device with GUID '{}' is in legal hold.".format(device_guid) + ) if purge_date: _update_cold_storage_purge_date(sdk, device_guid, purge_date) if change_device_name and not device.data["name"].startswith("deactivated_"): @@ -102,9 +104,13 @@ def _change_device_activation(sdk, device_guid, cmd_str): sdk.devices.deactivate(device_id) return device except exceptions.Py42NotFoundError: - raise Code42CLIError("The device {} was not found.".format(device_guid)) + raise Code42CLIError( + "The device with GUID '{}' was not found.".format(device_guid) + ) except exceptions.Py42ForbiddenError: - raise Code42CLIError("Unable to {} {}.".format(cmd_str, device_guid)) + raise Code42CLIError( + "Unable to {} the device with GUID '{}'.".format(cmd_str, device_guid) + ) def _verify_guid_type(device_guid): diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 3cbb11850..6281db4c3 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -432,7 +432,10 @@ def test_deactivate_fails_if_device_does_not_exist( cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state ) assert result.exit_code == 1 - assert "The device {} was not found.".format(TEST_DEVICE_GUID) in result.output + assert ( + "The device with GUID '{}' was not found.".format(TEST_DEVICE_GUID) + in result.output + ) def test_deactivate_fails_if_device_is_on_legal_hold( @@ -442,7 +445,10 @@ def test_deactivate_fails_if_device_is_on_legal_hold( cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state ) assert result.exit_code == 1 - assert "The device {} is in legal hold.".format(TEST_DEVICE_GUID) in result.output + assert ( + "The device with GUID '{}' is in legal hold.".format(TEST_DEVICE_GUID) + in result.output + ) def test_deactivate_fails_if_device_deactivation_forbidden( @@ -452,7 +458,10 @@ def test_deactivate_fails_if_device_deactivation_forbidden( cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state ) assert result.exit_code == 1 - assert "Unable to deactivate {}.".format(TEST_DEVICE_GUID) in result.output + assert ( + "Unable to deactivate the device with GUID '{}'.".format(TEST_DEVICE_GUID) + in result.output + ) def test_reactivate_reactivates_device( @@ -469,7 +478,7 @@ def test_reactivate_fails_if_device_does_not_exist( cli, ["devices", "reactivate", TEST_DEVICE_GUID], obj=cli_state ) assert result.exit_code == 1 - assert f"The device {TEST_DEVICE_GUID} was not found." in result.output + assert f"The device with GUID '{TEST_DEVICE_GUID}' was not found." in result.output def test_reactivate_fails_if_device_reactivation_forbidden( @@ -479,7 +488,10 @@ def test_reactivate_fails_if_device_reactivation_forbidden( cli, ["devices", "reactivate", TEST_DEVICE_GUID], obj=cli_state ) assert result.exit_code == 1 - assert f"Unable to reactivate {TEST_DEVICE_GUID}." in result.output + assert ( + f"Unable to reactivate the device with GUID '{TEST_DEVICE_GUID}'." + in result.output + ) def test_show_prints_device_info(runner, cli_state, backupusage_success): From 53c5ffe0bc77f18136a8111b29305086d713ca8b Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Tue, 26 Jan 2021 00:32:11 +0530 Subject: [PATCH 176/349] Fix list subcommand headers and make cases show display all fields in table format (#211) --- src/code42cli/cmds/cases.py | 21 +++++++++++---------- tests/cmds/test_cases.py | 37 +++++++++++++++++++++++++------------ 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index fb4cb472e..197ed68e7 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -20,7 +20,7 @@ name_option = click.option("--name", help="Name of the case.",) assignee_option = click.option("--assignee", help="User UID of the assignee.") description_option = click.option("--description", help="Description of the case.") -notes_option = click.option("--notes", help="Notes on the case.") +findings_option = click.option("--findings", help="Findings on the case.") subject_option = click.option("--subject", help="User UID of a subject of the case.") status_option = click.option( "--status", @@ -42,15 +42,16 @@ def _get_cases_header(): "name": "Name", "assignee": "Assignee", "status": "Status", + "subject": "Subject", "createdAt": "Creation Time", - "findings": "Notes", + "updatedAt": "Last Update Time", } def _get_events_header(): return { "eventId": "Event Id", - "eventTimestatmp": "Timestamp", + "eventTimestamp": "Timestamp", "filePath": "Path", "fileName": "File", "exposure": "Exposure", @@ -68,17 +69,17 @@ def cases(state): @click.argument("name") @assignee_option @description_option -@notes_option +@findings_option @subject_option @sdk_options() -def create(state, name, subject, assignee, description, notes): +def create(state, name, subject, assignee, description, findings): """Create a new case.""" state.sdk.cases.create( name, subject=subject, assignee=assignee, description=description, - findings=notes, + findings=findings, ) @@ -87,11 +88,11 @@ def create(state, name, subject, assignee, description, notes): @name_option @assignee_option @description_option -@notes_option +@findings_option @subject_option @status_option @sdk_options() -def update(state, case_number, name, subject, assignee, description, notes, status): +def update(state, case_number, name, subject, assignee, description, findings, status): """Update case details for the given case.""" state.sdk.cases.update( case_number, @@ -99,7 +100,7 @@ def update(state, case_number, name, subject, assignee, description, notes, stat subject=subject, assignee=assignee, description=description, - findings=notes, + findings=findings, status=status, ) @@ -172,7 +173,7 @@ def _display_file_events(events): @format_option def show(state, case_number, format, include_file_events): """Show case details.""" - formatter = OutputFormatter(format, _get_cases_header()) + formatter = OutputFormatter(format) try: response = state.sdk.cases.get(case_number) formatter.echo_formatted_list([response.data]) diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index 231f8181e..00856c857 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -10,18 +10,28 @@ from code42cli.main import cli -EVENT_DETAILS = """{"eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971"} -""" - -ALL_EVENTS = """{"events": [{"eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971"}]}""" - -ALL_CASES = """{"cases": [{"number": 3,"name": "test@test.test"}], "totalCount": 31}""" - -CASE_DETAILS = """{"number": 3, "name": "test@test.test"}""" - +ALL_EVENTS = """{ + "events": [ + { + "eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971", + "eventTimestamp": "2020-12-23T12:41:38.592Z" + } + ] +}""" +ALL_CASES = """{ + "cases": [ + { + "number": 3, + "name": "test@test.test", + "updatedAt": "2021-01-24T11:00:04.217878Z", + "subject": "942897" + } + ], + "totalCount": 31 +}""" +CASE_DETAILS = '{"number": 3, "name": "test@test.test"}' CASES_COMMAND = "cases" CASES_FILE_EVENTS_COMMAND = "cases file-events" - MISSING_ARGUMENT_ERROR = "Missing argument '{}'." MISSING_NAME = MISSING_ARGUMENT_ERROR.format("NAME") MISSING_CASE_NUMBER = MISSING_ARGUMENT_ERROR.format("CASE_NUMBER") @@ -63,7 +73,7 @@ def test_create_with_optional_fields_calls_create_with_expected_params( "a", "--description", "d", - "--notes", + "--findings", "n", "--subject", "s", @@ -90,7 +100,7 @@ def test_update_with_optional_fields_calls_update_with_expected_params( "a", "--description", "d", - "--notes", + "--findings", "n", "--subject", "s", @@ -227,6 +237,8 @@ def gen(): cli_state.sdk.cases.get_all.return_value = gen() result = runner.invoke(cli, ["cases", "list"], obj=cli_state,) assert "test@test.test" in result.output + assert "2021-01-24T11:00:04.217878Z" in result.output + assert "942897" in result.output def test_show_returns_expected_data_with_include_file_events_option( @@ -250,6 +262,7 @@ def test_events_list_returns_expected_data(runner, cli_state): "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971" in result.output ) + assert "2020-12-23T12:41:38.592Z" in result.output @pytest.mark.parametrize( From 6cf128046101577f49de00376bdcf6e8e82bb1d6 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 25 Jan 2021 14:18:19 -0600 Subject: [PATCH 177/349] org and split up tests (#216) --- src/code42cli/cmds/cases.py | 7 +- tests/cmds/test_cases.py | 209 +++++++++++++++++++++--------------- 2 files changed, 130 insertions(+), 86 deletions(-) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index 197ed68e7..3674c7796 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -17,6 +17,9 @@ case_number_arg = click.argument("case-number", type=int) +case_number_option = click.option( + "--case-number", type=int, help="The number assigned to the case.", required=True +) name_option = click.option("--name", help="Name of the case.",) assignee_option = click.option("--assignee", help="User UID of the assignee.") description_option = click.option("--description", help="Description of the case.") @@ -225,7 +228,7 @@ def file_events_list(state, case_number, format): @file_events.command() -@case_number_arg +@case_number_option @file_event_id_option @sdk_options() def add(state, case_number, event_id): @@ -237,7 +240,7 @@ def add(state, case_number, event_id): @file_events.command() -@case_number_arg +@case_number_option @file_event_id_option @sdk_options() def remove(state, case_number, event_id): diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index 00856c857..048e8b219 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -30,13 +30,12 @@ "totalCount": 31 }""" CASE_DETAILS = '{"number": 3, "name": "test@test.test"}' -CASES_COMMAND = "cases" -CASES_FILE_EVENTS_COMMAND = "cases file-events" MISSING_ARGUMENT_ERROR = "Missing argument '{}'." MISSING_NAME = MISSING_ARGUMENT_ERROR.format("NAME") -MISSING_CASE_NUMBER = MISSING_ARGUMENT_ERROR.format("CASE_NUMBER") +MISSING_CASE_NUMBER_ARG = MISSING_ARGUMENT_ERROR.format("CASE_NUMBER") MISSING_OPTION_ERROR = "Missing option '--{}'." MISSING_EVENT_ID = MISSING_OPTION_ERROR.format("event-id") +MISSING_CASE_NUMBER_OPTION = MISSING_OPTION_ERROR.format("case-number") @pytest.fixture @@ -85,6 +84,13 @@ def test_create_with_optional_fields_calls_create_with_expected_params( ) +def test_create_when_missing_name_prints_error(runner, cli_state): + command = ["cases", "create", "--description", "d"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_NAME in result.output + + def test_update_with_optional_fields_calls_update_with_expected_params( runner, cli_state ): @@ -135,6 +141,13 @@ def test_update_calls_update_with_expected_params(runner, cli_state): ) +def test_update_when_missing_case_number_prints_error(runner, cli_state): + command = ["cases", "update", "--description", "d"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_CASE_NUMBER_ARG in result.output + + def test_list_calls_get_all_with_expected_params(runner, cli_state): runner.invoke( cli, ["cases", "list"], obj=cli_state, @@ -142,6 +155,19 @@ def test_list_calls_get_all_with_expected_params(runner, cli_state): assert cli_state.sdk.cases.get_all.call_count == 1 +def test_list_prints_expected_data(runner, cli_state, py42_response): + py42_response.data = json.loads(ALL_CASES) + + def gen(): + yield py42_response.data + + cli_state.sdk.cases.get_all.return_value = gen() + result = runner.invoke(cli, ["cases", "list"], obj=cli_state,) + assert "test@test.test" in result.output + assert "2021-01-24T11:00:04.217878Z" in result.output + assert "942897" in result.output + + def test_show_calls_get_case_with_expected_params(runner, cli_state): runner.invoke( cli, ["cases", "show", "1"], obj=cli_state, @@ -158,6 +184,43 @@ def test_show_with_include_file_events_calls_file_events_get_all_with_expected_p cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) +def test_show_when_py42_raises_exception_prints_error_message(runner, cli_state, error): + cli_state.sdk.cases.file_events.get_all.side_effect = Py42NotFoundError(error) + result = runner.invoke( + cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, + ) + cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) + assert "Invalid case-number 1." in result.output + + +def test_show_prints_expected_data(runner, cli_state, py42_response): + py42_response.data = json.loads(CASE_DETAILS) + cli_state.sdk.cases.get.return_value = py42_response + result = runner.invoke(cli, ["cases", "show", "1"], obj=cli_state,) + assert "test@test.test" in result.output + + +def test_show_prints_expected_data_with_include_file_events_option( + runner, cli_state, py42_response +): + py42_response.text = ALL_EVENTS + cli_state.sdk.cases.file_events.get_all.return_value = py42_response + result = runner.invoke( + cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, + ) + assert ( + "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971" + in result.output + ) + + +def test_show_case_when_missing_case_number_prints_error(runner, cli_state): + command = ["cases", "show"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_CASE_NUMBER_ARG in result.output + + def test_export_calls_export_summary_with_expected_params(runner, cli_state, mocker): with mock.patch("builtins.open", mock_open()) as mf: runner.invoke( @@ -167,95 +230,93 @@ def test_export_calls_export_summary_with_expected_params(runner, cli_state, moc mf.assert_called_once_with("./1_case_summary.pdf", "wb") +def test_export_when_missing_case_number_prints_error(runner, cli_state): + command = ["cases", "export"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_CASE_NUMBER_ARG in result.output + + def test_file_events_add_calls_add_event_with_expected_params(runner, cli_state): runner.invoke( - cli, ["cases", "file-events", "add", "1", "--event-id", "1"], obj=cli_state, + cli, + ["cases", "file-events", "add", "--case-number", "1", "--event-id", "1"], + obj=cli_state, ) cli_state.sdk.cases.file_events.add.assert_called_once_with(1, "1") -def test_file_events_remove_calls_delete_event_with_expected_params(runner, cli_state): - runner.invoke( - cli, ["cases", "file-events", "remove", "1", "--event-id", "1"], obj=cli_state, +def test_file_events_add_when_py42_raises_exception_prints_error_message( + runner, cli_state, error +): + cli_state.sdk.cases.file_events.add.side_effect = Py42BadRequestError(error) + result = runner.invoke( + cli, + ["cases", "file-events", "add", "--case-number", "1", "--event-id", "1"], + obj=cli_state, ) - cli_state.sdk.cases.file_events.delete.assert_called_once_with(1, "1") + cli_state.sdk.cases.file_events.add.assert_called_once_with(1, "1") + assert "Invalid case-number or event-id." in result.output -def test_file_events_list_calls_get_all_with_expected_params(runner, cli_state): - runner.invoke( - cli, ["cases", "file-events", "list", "1"], obj=cli_state, - ) - cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) +def test_file_events_add_when_missing_event_id_prints_error(runner, cli_state): + command = ["cases", "file-events", "remove", "--case-number", "4"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_EVENT_ID in result.output -def test_show_when_py42_raises_exception_returns_error_message( - runner, cli_state, error -): - cli_state.sdk.cases.file_events.get_all.side_effect = Py42NotFoundError(error) - result = runner.invoke( - cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, - ) - cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) - assert "Invalid case-number 1." in result.output +def test_file_events_add_when_missing_case_number_prints_error(runner, cli_state): + command = ["cases", "file-events", "add"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_CASE_NUMBER_OPTION in result.output -def test_file_events_add_when_py42_raises_exception_returns_error_message( - runner, cli_state, error -): - cli_state.sdk.cases.file_events.add.side_effect = Py42BadRequestError(error) - result = runner.invoke( - cli, ["cases", "file-events", "add", "1", "--event-id", "1"], obj=cli_state, +def test_file_events_remove_calls_delete_event_with_expected_params(runner, cli_state): + runner.invoke( + cli, + ["cases", "file-events", "remove", "--case-number", "1", "--event-id", "1"], + obj=cli_state, ) - cli_state.sdk.cases.file_events.add.assert_called_once_with(1, "1") - assert "Invalid case-number or event-id." in result.output + cli_state.sdk.cases.file_events.delete.assert_called_once_with(1, "1") -def test_file_events_remove_when_py42_raises_exception_returns_error_message( +def test_file_events_remove_when_py42_raises_exception_prints_error_message( runner, cli_state, error ): cli_state.sdk.cases.file_events.delete.side_effect = Py42NotFoundError(error) result = runner.invoke( - cli, ["cases", "file-events", "remove", "1", "--event-id", "1"], obj=cli_state, + cli, + ["cases", "file-events", "remove", "--case-number", "1", "--event-id", "1"], + obj=cli_state, ) cli_state.sdk.cases.file_events.delete.assert_called_once_with(1, "1") assert "Invalid case-number or event-id." in result.output -def test_show_returns_expected_data(runner, cli_state, py42_response): - py42_response.data = json.loads(CASE_DETAILS) - cli_state.sdk.cases.get.return_value = py42_response - result = runner.invoke(cli, ["cases", "show", "1"], obj=cli_state,) - assert "test@test.test" in result.output - +def test_file_events_remove_when_missing_event_id_prints_error(runner, cli_state): + command = ["cases", "file-events", "remove", "--case-number", "4"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_EVENT_ID in result.output -def test_list_returns_expected_data(runner, cli_state, py42_response): - py42_response.data = json.loads(ALL_CASES) - def gen(): - yield py42_response.data - - cli_state.sdk.cases.get_all.return_value = gen() - result = runner.invoke(cli, ["cases", "list"], obj=cli_state,) - assert "test@test.test" in result.output - assert "2021-01-24T11:00:04.217878Z" in result.output - assert "942897" in result.output +def test_file_events_remove_when_missing_case_number_prints_error(runner, cli_state): + command = ["cases", "file-events", "add"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_CASE_NUMBER_OPTION in result.output -def test_show_returns_expected_data_with_include_file_events_option( - runner, cli_state, py42_response -): - py42_response.text = ALL_EVENTS - cli_state.sdk.cases.file_events.get_all.return_value = py42_response - result = runner.invoke( - cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, - ) - assert ( - "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971" - in result.output +def test_file_events_list_calls_get_all_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["cases", "file-events", "list", "1"], obj=cli_state, ) + cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) -def test_events_list_returns_expected_data(runner, cli_state): +def test_file_events_list_prints_expected_data(runner, cli_state): cli_state.sdk.cases.file_events.get_all.return_value = json.loads(ALL_EVENTS) result = runner.invoke(cli, ["cases", "file-events", "list", "1"], obj=cli_state,) assert ( @@ -265,28 +326,8 @@ def test_events_list_returns_expected_data(runner, cli_state): assert "2020-12-23T12:41:38.592Z" in result.output -@pytest.mark.parametrize( - "command, error_msg", - [ - ("{} create --description d".format(CASES_COMMAND), MISSING_NAME), - ("{} update --description d".format(CASES_COMMAND), MISSING_CASE_NUMBER), - ("{} show".format(CASES_COMMAND), MISSING_CASE_NUMBER), - ("{} export".format(CASES_COMMAND), MISSING_CASE_NUMBER), - ("{} add".format(CASES_FILE_EVENTS_COMMAND), MISSING_CASE_NUMBER), - ("{} add --event-id 3".format(CASES_FILE_EVENTS_COMMAND), MISSING_CASE_NUMBER), - ("{} add 3".format(CASES_FILE_EVENTS_COMMAND), MISSING_EVENT_ID), - ("{} remove 3".format(CASES_FILE_EVENTS_COMMAND), MISSING_EVENT_ID), - ("{} remove".format(CASES_FILE_EVENTS_COMMAND), MISSING_CASE_NUMBER), - ( - "{} remove --event-id 3".format(CASES_FILE_EVENTS_COMMAND), - MISSING_CASE_NUMBER, - ), - ("{} list".format(CASES_FILE_EVENTS_COMMAND), MISSING_CASE_NUMBER), - ], -) -def test_cases_command_when_missing_required_parameters_errors( - command, error_msg, runner, cli_state -): - result = runner.invoke(cli, command.split(" "), obj=cli_state) +def test_file_events_list_when_missing_case_number_prints_error(runner, cli_state): + command = ["cases", "file-events", "list"] + result = runner.invoke(cli, command, obj=cli_state) assert result.exit_code == 2 - assert error_msg in "".join(result.output) + assert MISSING_CASE_NUMBER_ARG in result.output From 6dc5c409e704bf29359648fc6bdf8e685e81df10 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 25 Jan 2021 15:47:25 -0600 Subject: [PATCH 178/349] JSON output format and other fixes (#217) --- src/code42cli/cmds/cases.py | 30 +++++++++++++++++------------- src/code42cli/cmds/devices.py | 25 ++++++++++++++++--------- tests/cmds/test_cases.py | 4 +++- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index 3674c7796..2289b0126 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -1,6 +1,5 @@ import json import os -from pprint import pformat import click from py42.clients.cases import CaseStatus @@ -20,20 +19,23 @@ case_number_option = click.option( "--case-number", type=int, help="The number assigned to the case.", required=True ) -name_option = click.option("--name", help="Name of the case.",) -assignee_option = click.option("--assignee", help="User UID of the assignee.") -description_option = click.option("--description", help="Description of the case.") -findings_option = click.option("--findings", help="Findings on the case.") -subject_option = click.option("--subject", help="User UID of a subject of the case.") +name_option = click.option("--name", help="The name of the case.",) +assignee_option = click.option( + "--assignee", help="The UID of the user to assign to the case." +) +description_option = click.option("--description", help="The description of the case.") +findings_option = click.option("--findings", help="Any findings for the case.") +subject_option = click.option( + "--subject", help="The user UID of the subject of the case." +) status_option = click.option( "--status", help="Status of the case. `OPEN` or `CLOSED`.", type=click.Choice(CaseStatus.choices()), ) file_event_id_option = click.option( - "--event-id", required=True, help="File event id associated to the case." + "--event-id", required=True, help="The file event ID associated with the case." ) - CASES_KEYWORD = "cases" BEGIN_DATE_DICT = set_begin_default_dict(CASES_KEYWORD) END_DATE_DICT = set_end_default_dict(CASES_KEYWORD) @@ -110,10 +112,10 @@ def update(state, case_number, name, subject, assignee, description, findings, s @cases.command("list") @click.option( - "--name", help="Filter by name of a case, supports partial name matches.", + "--name", help="Filter by name of a case. Supports partial name matches.", ) -@click.option("--subject", help="Filter by user UID of the subject of a case.") -@click.option("--assignee", help="Filter by user UID of assignee.") +@click.option("--subject", help="Filter by the user UID of the subject of a case.") +@click.option("--assignee", help="Filter by the user UID of an assignee.") @click.option("--begin-create-time", **BEGIN_DATE_DICT) @click.option("--end-create-time", **END_DATE_DICT) @click.option("--begin-update-time", **BEGIN_DATE_DICT) @@ -162,7 +164,7 @@ def _get_file_events(sdk, case_number): def _display_file_events(events): if events: click.echo("\nFile Events:\n") - click.echo(pformat(events)) + click.echo(json.dumps(events, indent=4)) else: click.echo("\nNo events found.") @@ -190,7 +192,9 @@ def show(state, case_number, format, include_file_events): @cases.command() @case_number_arg @click.option( - "--path", help="File path. Defaults to the current directory.", default="." + "--path", + help="The file path where to save the PDF. Defaults to the current directory.", + default=os.getcwd(), ) @sdk_options() def export(state, case_number, path): diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 9c6aad519..8b0a920aa 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -33,13 +33,16 @@ def devices(state): "device-guid", type=str, callback=lambda ctx, param, arg: _verify_guid_type(arg), ) -change_device_name_option = click.option( - "--change-device-name", - required=False, - is_flag=True, - default=False, - help="Prepend 'deactivated_' to the name of any deactivated devices.", -) + +def change_device_name_option(help_msg): + return click.option( + "--change-device-name", + required=False, + is_flag=True, + default=False, + help=help_msg, + ) + DATE_FORMAT = "%Y-%m-%d" purge_date_option = click.option( @@ -54,7 +57,9 @@ def devices(state): @devices.command() @device_guid_argument -@change_device_name_option +@change_device_name_option( + "Prepend 'deactivated_' to the name of the device if deactivation is successful." +) @purge_date_option @sdk_options() def deactivate(state, device_guid, change_device_name, purge_date): @@ -479,7 +484,9 @@ def bulk(state): @bulk.command(name="deactivate") @read_csv_arg(headers=_bulk_device_activation_headers) -@change_device_name_option +@change_device_name_option( + "Prepend 'deactivated_' to the name of any successfully deactivated devices." +) @purge_date_option @format_option @sdk_options() diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index 048e8b219..4dc19ec1c 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -1,4 +1,5 @@ import json +import os from unittest import mock from unittest.mock import mock_open @@ -227,7 +228,8 @@ def test_export_calls_export_summary_with_expected_params(runner, cli_state, moc cli, ["cases", "export", "1"], obj=cli_state, ) cli_state.sdk.cases.export_summary.assert_called_once_with(1) - mf.assert_called_once_with("./1_case_summary.pdf", "wb") + expected = os.path.join(os.getcwd(), "1_case_summary.pdf") + mf.assert_called_once_with(expected, "wb") def test_export_when_missing_case_number_prints_error(runner, cli_state): From 7b91032854aa6402e904c22d327cf989b90e46f2 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 25 Jan 2021 16:06:21 -0600 Subject: [PATCH 179/349] Adds missing word to sentence in changelog (#218) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46178e776..31b9e1fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,13 +35,13 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 cases file-events` commands: - `add` to add an event to a case. - - `remove` to remove an event from the case. + - `remove` to remove an event from a case. - `list` to view all events associated with a case. ### Changed - The error text when removing an employee from a detection list now references the employee - by ID rather the username. + by ID rather than the username. - Improved help text for date option arguments. From ca45ee97dacad033cb164d394871adc2b73c03c5 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 28 Jan 2021 09:38:52 -0600 Subject: [PATCH 180/349] add hidden yes_option to `profile create` cmd (#219) --- src/code42cli/cmds/profile.py | 5 +++-- src/code42cli/options.py | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 5d067050d..03351b86e 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -70,6 +70,7 @@ def show(profile_name): @server_option @username_option @password_option +@yes_option(hidden=True) @disable_ssl_option def create(name, server, username, password, disable_ssl_errors): """Create profile settings. The first profile created will be the default.""" @@ -128,7 +129,7 @@ def use(profile_name): @profile.command() -@yes_option +@yes_option() @profile_name_arg(required=True) def delete(profile_name): """Deletes a profile and its stored password (if any).""" @@ -143,7 +144,7 @@ def delete(profile_name): @profile.command() -@yes_option +@yes_option() def delete_all(): """Deletes all profiles and saved passwords (if any).""" existing_profiles = cliprofile.get_all_profiles() diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 37ff56747..bea3f03c2 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -14,14 +14,17 @@ from code42cli.sdk_client import create_sdk -yes_option = click.option( - "-y", - "--assume-yes", - is_flag=True, - expose_value=False, - callback=lambda ctx, param, value: ctx.obj.set_assume_yes(value), - help='Assume "yes" as the answer to all prompts and run non-interactively.', -) +def yes_option(hidden=False): + return click.option( + "-y", + "--assume-yes", + is_flag=True, + expose_value=False, + callback=lambda ctx, param, value: ctx.obj.set_assume_yes(value), + help='Assume "yes" as the answer to all prompts and run non-interactively.', + hidden=hidden, + ) + format_option = click.option( "-f", From b94569c712655ebe738206445c3af8a88a9632e4 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 28 Jan 2021 20:00:07 -0600 Subject: [PATCH 181/349] fix cmd prog (#220) --- docs/commands/auditlogs.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands/auditlogs.rst b/docs/commands/auditlogs.rst index f8ee5921d..c2191b271 100644 --- a/docs/commands/auditlogs.rst +++ b/docs/commands/auditlogs.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.auditlogs:audit_logs - :prog: auditlogs + :prog: audit-logs :show-nested: From de6fb41592f7cbdbebec51a2401cae3e266883a0 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 1 Feb 2021 08:12:32 -0600 Subject: [PATCH 182/349] Update cases.py (#221) --- src/code42cli/cmds/cases.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index 2289b0126..d232a9660 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -172,7 +172,9 @@ def _display_file_events(events): @cases.command() @case_number_arg @click.option( - "--include-file-events", is_flag=True, help="View events associated to the case." + "--include-file-events", + is_flag=True, + help="View file events associated to the case.", ) @sdk_options() @format_option @@ -198,7 +200,7 @@ def show(state, case_number, format, include_file_events): ) @sdk_options() def export(state, case_number, path): - """Download a case detail summary as a pdf file at the given path with name _case_summary.pdf.""" + """Download a case detail summary as a PDF file at the given path with name _case_summary.pdf.""" response = state.sdk.cases.export_summary(case_number) file = os.path.join(path, "{}_case_summary.pdf".format(case_number)) with open(file, "wb") as f: @@ -217,7 +219,7 @@ def file_events(state): @sdk_options() @format_option def file_events_list(state, case_number, format): - """List all the events associated with the case.""" + """List all the file events associated with the case.""" formatter = OutputFormatter(format, _get_events_header()) try: response = state.sdk.cases.file_events.get_all(case_number) @@ -236,7 +238,7 @@ def file_events_list(state, case_number, format): @file_event_id_option @sdk_options() def add(state, case_number, event_id): - """Associate an event id to a case.""" + """Associate a file event to a case, by event ID.""" try: state.sdk.cases.file_events.add(case_number, event_id) except Py42BadRequestError: @@ -248,7 +250,7 @@ def add(state, case_number, event_id): @file_event_id_option @sdk_options() def remove(state, case_number, event_id): - """Remove the associated event id from the case.""" + """Remove the associated file event from the case, by event ID.""" try: state.sdk.cases.file_events.delete(case_number, event_id) except Py42NotFoundError: From 486ea39e9fb076dab697c00bb3a2e5e61be0cfdc Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 2 Feb 2021 07:42:41 -0600 Subject: [PATCH 183/349] Feature/ssl sendto (#190) SSL Send to option --- CHANGELOG.md | 12 + src/code42cli/click_ext/groups.py | 2 + src/code42cli/cmds/alerts.py | 74 +- src/code42cli/cmds/auditlogs.py | 33 +- src/code42cli/cmds/detectionlists/__init__.py | 8 +- src/code42cli/cmds/search/__init__.py | 30 + src/code42cli/cmds/search/enums.py | 18 - src/code42cli/cmds/search/options.py | 38 + src/code42cli/cmds/securitydata.py | 169 ++-- .../cmds/securitydata_output_formats.py | 106 --- .../{logger.py => logger/__init__.py} | 43 +- src/code42cli/logger/enums.py | 7 + src/code42cli/logger/formatters.py | 139 ++++ src/code42cli/logger/handlers.py | 145 ++++ src/code42cli/maps.py | 52 ++ src/code42cli/options.py | 2 +- src/code42cli/output_formats.py | 36 + tests/cmds/conftest.py | 10 +- tests/cmds/search/test_enums.py | 8 - tests/cmds/search/test_init.py | 55 ++ tests/cmds/test_alert_rules.py | 2 +- tests/cmds/test_alerts.py | 704 +++++----------- tests/cmds/test_auditlogs.py | 258 +++--- tests/cmds/test_devices.py | 4 +- tests/cmds/test_legal_hold.py | 2 +- tests/cmds/test_securitydata.py | 759 +++++++++--------- .../cmds/test_securitydata_output_formats.py | 573 ------------- tests/logger/__init__.py | 0 tests/logger/conftest.py | 119 +++ tests/logger/test_formatters.py | 546 +++++++++++++ tests/logger/test_handlers.py | 238 ++++++ tests/logger/test_init.py | 168 ++++ tests/test_logger.py | 156 ---- tests/test_output_formats.py | 596 +++++++++++++- 34 files changed, 3024 insertions(+), 2088 deletions(-) delete mode 100644 src/code42cli/cmds/search/enums.py delete mode 100644 src/code42cli/cmds/securitydata_output_formats.py rename src/code42cli/{logger.py => logger/__init__.py} (73%) create mode 100644 src/code42cli/logger/enums.py create mode 100644 src/code42cli/logger/formatters.py create mode 100644 src/code42cli/logger/handlers.py create mode 100644 src/code42cli/maps.py delete mode 100644 tests/cmds/search/test_enums.py create mode 100644 tests/cmds/search/test_init.py delete mode 100644 tests/cmds/test_securitydata_output_formats.py create mode 100644 tests/logger/__init__.py create mode 100644 tests/logger/conftest.py create mode 100644 tests/logger/test_formatters.py create mode 100644 tests/logger/test_handlers.py create mode 100644 tests/logger/test_init.py delete mode 100644 tests/test_logger.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b9e1fa9..c55f5fb6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- New choice `TLS-TCP` for `--protocol` option used by `send-to` commands: + - `code42 security-data send-to` + - `code42 alerts send-to` + - `code42 audit-logs send-to` + for more securely transporting data. + +- `--certs` option for `send-to` commands when using `--protocol TLS-TCP`. + ## 1.2.0 - 2021-01-25 ### Added diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index 3328941f5..fcf2d3402 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -14,6 +14,7 @@ from code42cli.errors import LoggedCLIError from code42cli.errors import UserDoesNotExistError from code42cli.logger import get_main_cli_logger +from code42cli.logger.handlers import SyslogServerNetworkConnectionError _DIFFLIB_CUT_OFF = 0.6 @@ -57,6 +58,7 @@ def invoke(self, ctx): Py42UserNotOnListError, Py42InvalidRuleOperationError, Py42LegalHoldNotFoundOrPermissionDeniedError, + SyslogServerNetworkConnectionError, ) as err: self.logger.log_error(err) raise Code42CLIError(str(err)) diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index ca2d8c284..dd6e545a8 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -1,5 +1,3 @@ -from _collections import OrderedDict - import click import py42.sdk.queries.alerts.filters as f from c42eventextractor.extractors import AlertExtractor @@ -12,26 +10,18 @@ import code42cli.cmds.search.options as searchopt import code42cli.errors as errors import code42cli.options as opt +from code42cli.cmds.search import SendToCommand from code42cli.cmds.search.cursor_store import AlertCursorStore from code42cli.cmds.search.extraction import handle_no_events +from code42cli.cmds.search.options import server_options from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import limit_date_range -from code42cli.logger import get_logger_for_server from code42cli.options import format_option -from code42cli.options import server_options from code42cli.output_formats import JsonOutputFormat from code42cli.output_formats import OutputFormatter -ALERTS_KEYWORD = "alerts" -SEARCH_DEFAULT_HEADER = OrderedDict() -SEARCH_DEFAULT_HEADER["name"] = "RuleName" -SEARCH_DEFAULT_HEADER["actor"] = "Username" -SEARCH_DEFAULT_HEADER["createdAt"] = "ObservedDate" -SEARCH_DEFAULT_HEADER["state"] = "Status" -SEARCH_DEFAULT_HEADER["severity"] = "Severity" -SEARCH_DEFAULT_HEADER["description"] = "Description" - +ALERTS_KEYWORD = "alerts" begin = opt.begin_option( ALERTS_KEYWORD, callback=lambda ctx, param, arg: convert_datetime_to_timestamp( @@ -41,16 +31,6 @@ end = opt.end_option(ALERTS_KEYWORD) checkpoint = opt.checkpoint_option(ALERTS_KEYWORD) advanced_query = searchopt.advanced_query_option(ALERTS_KEYWORD) - - -def search_options(f): - f = checkpoint(f) - f = advanced_query(f) - f = end(f) - f = begin(f) - return f - - severity_option = click.option( "--severity", multiple=True, @@ -147,16 +127,34 @@ def search_options(f): callback=searchopt.contains_filter(f.Description), help="Filter alerts by description. Does fuzzy search by default.", ) - send_to_format_options = click.option( "-f", "--format", type=click.Choice(JsonOutputFormat(), case_sensitive=False), help="The output format of the result. Defaults to json format.", - default=JsonOutputFormat.JSON, + default=JsonOutputFormat.RAW, ) +def _get_search_default_header(): + return { + "name": "RuleName", + "actor": "Username", + "createdAt": "ObservedDate", + "state": "Status", + "severity": "Severity", + "description": "Description", + } + + +def search_options(f): + f = checkpoint(f) + f = advanced_query(f) + f = end(f) + f = begin(f) + return f + + def alert_options(f): f = actor_option(f) f = actor_contains_option(f) @@ -231,7 +229,7 @@ def search( ): """Search for alerts.""" output_header = ext.try_get_default_header( - include_all, SEARCH_DEFAULT_HEADER, format + include_all, _get_search_default_header(), format ) formatter = OutputFormatter(format, output_header) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None @@ -247,7 +245,7 @@ def search( handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) -@alerts.command() +@alerts.command(cls=SendToCommand) @alert_options @search_options @click.option( @@ -262,31 +260,23 @@ def search( help="Display simple properties of the primary level of the nested response.", ) @send_to_format_options -def send_to( - cli_state, - format, - hostname, - protocol, - begin, - end, - advanced_query, - use_checkpoint, - or_query, - **kwargs -): +def send_to(cli_state, begin, end, advanced_query, use_checkpoint, or_query, **kwargs): """Send alerts to the given server address. HOSTNAME format: address:port where port is optional and defaults to 514. """ - logger = get_logger_for_server(hostname, protocol, format) - cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None + cursor = _get_cursor(cli_state, use_checkpoint) handlers = ext.create_send_to_handlers( - cli_state.sdk, AlertExtractor, cursor, use_checkpoint, logger, + cli_state.sdk, AlertExtractor, cursor, use_checkpoint, cli_state.logger, ) _call_extractor(cli_state, handlers, begin, end, or_query, advanced_query, **kwargs) handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) +def _get_cursor(state, use_checkpoint): + return _get_alert_cursor_store(state.profile.name) if use_checkpoint else None + + def _get_alert_extractor(sdk, handlers): return AlertExtractor(sdk, handlers) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index 7814305bf..9488b0338 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -1,4 +1,3 @@ -from collections import OrderedDict from datetime import datetime from datetime import timezone @@ -6,13 +5,13 @@ import code42cli.options as opt from code42cli.click_ext.groups import OrderedGroup +from code42cli.cmds.search import SendToCommand from code42cli.cmds.search.cursor_store import AuditLogCursorStore +from code42cli.cmds.search.options import server_options from code42cli.date_helper import convert_datetime_to_timestamp -from code42cli.logger import get_logger_for_server from code42cli.options import checkpoint_option from code42cli.options import format_option from code42cli.options import sdk_options -from code42cli.options import server_options from code42cli.output_formats import OutputFormatter from code42cli.util import hash_event from code42cli.util import warn_interrupt @@ -21,13 +20,17 @@ AUDIT_LOGS_KEYWORD = "audit-logs" AUDIT_LOG_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" -AUDIT_LOGS_DEFAULT_HEADER = OrderedDict() -AUDIT_LOGS_DEFAULT_HEADER["timestamp"] = "Timestamp" -AUDIT_LOGS_DEFAULT_HEADER["type$"] = "Type" -AUDIT_LOGS_DEFAULT_HEADER["actorName"] = "ActorName" -AUDIT_LOGS_DEFAULT_HEADER["actorIpAddress"] = "ActorIpAddress" -AUDIT_LOGS_DEFAULT_HEADER["userName"] = "AffectedUser" -AUDIT_LOGS_DEFAULT_HEADER["userId"] = "AffectedUserUID" + +def _get_audit_logs_default_header(): + return { + "timestamp": "Timestamp", + "type$": "Type", + "actorName": "ActorName", + "actorIpAddress": "ActorIpAddress", + "userName": "AffectedUser", + "userId": "AffectedUserUID", + } + begin_option = opt.begin_option( AUDIT_LOGS_KEYWORD, @@ -123,7 +126,7 @@ def search( use_checkpoint, ): """Search audit logs.""" - formatter = OutputFormatter(format, AUDIT_LOGS_DEFAULT_HEADER) + formatter = OutputFormatter(format, _get_audit_logs_default_header()) cursor = _get_audit_log_cursor_store(state.profile.name) if use_checkpoint: checkpoint_name = use_checkpoint @@ -158,15 +161,13 @@ def search( formatter.echo_formatted_list(events) -@audit_logs.command() +@audit_logs.command(cls=SendToCommand) @filter_options @checkpoint_option(AUDIT_LOGS_KEYWORD) @server_options @sdk_options() def send_to( state, - hostname, - protocol, begin, end, event_type, @@ -176,12 +177,12 @@ def send_to( affected_user_id, affected_username, use_checkpoint, + **kwargs, ): """Send audit logs to the given server address in JSON format. HOSTNAME format: address:port where port is optional and defaults to 514. """ - logger = get_logger_for_server(hostname, protocol, "RAW-JSON") cursor = _get_audit_log_cursor_store(state.profile.name) if use_checkpoint: checkpoint_name = use_checkpoint @@ -210,7 +211,7 @@ def send_to( with warn_interrupt(): event = None for event in events: - logger.info(event) + state.logger.info(event) if event is None: # generator was empty click.echo("No results found.") diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index 3a2bb061c..a332888fb 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -48,10 +48,10 @@ def update_user(sdk, username, cloud_alias=None, risk_tag=None, notes=None): Args: sdk (py42.sdk.SDKClient): py42 sdk. - username (str or unicode): The username of the user to update. - cloud_alias (str or unicode): A cloud alias to add to the user. - risk_tag (iter[str or unicode]): A list of risk tags associated with user. - notes (str or unicode): Notes about the user. + username (str): The username of the user to update. + cloud_alias (str): A cloud alias to add to the user. + risk_tag (iter[str]): A list of risk tags associated with user. + notes (str): Notes about the user. """ user_id = get_user_id(sdk, username) _update_cloud_alias(sdk, user_id, cloud_alias) diff --git a/src/code42cli/cmds/search/__init__.py b/src/code42cli/cmds/search/__init__.py index e69de29bb..00b540d94 100644 --- a/src/code42cli/cmds/search/__init__.py +++ b/src/code42cli/cmds/search/__init__.py @@ -0,0 +1,30 @@ +import click + +from code42cli.errors import Code42CLIError +from code42cli.logger import get_logger_for_server +from code42cli.output_formats import OutputFormat + + +def _try_get_logger_for_server(hostname, protocol, output_format, certs): + try: + return get_logger_for_server(hostname, protocol, output_format, certs) + except Exception as err: + raise Code42CLIError( + "Unable to connect to {}. Failed with error: {}.".format(hostname, str(err)) + ) + + +class SendToCommand(click.Command): + def invoke(self, ctx): + certs = ctx.params.get("certs") + hostname = ctx.params.get("hostname") + protocol = ctx.params.get("protocol") + output_format = ctx.params.get("format", OutputFormat.RAW) + ignore_cert_validation = ctx.params.get("ignore_cert_validation") + if ignore_cert_validation: + certs = "ignore" + + ctx.obj.logger = _try_get_logger_for_server( + hostname, protocol, output_format, certs + ) + return super().invoke(ctx) diff --git a/src/code42cli/cmds/search/enums.py b/src/code42cli/cmds/search/enums.py deleted file mode 100644 index 09c8d5e8b..000000000 --- a/src/code42cli/cmds/search/enums.py +++ /dev/null @@ -1,18 +0,0 @@ -from code42cli.output_formats import OutputFormat - -IS_CHECKPOINT_KEY = "use_checkpoint" - - -class FileEventsOutputFormat(OutputFormat): - CEF = "CEF" - - def __iter__(self): - return iter([self.TABLE, self.CSV, self.JSON, self.RAW, self.CEF]) - - -class ServerProtocol: - TCP = "TCP" - UDP = "UDP" - - def __iter__(self): - return iter([self.TCP, self.UDP]) diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index fc9f7e932..20c93a117 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -7,6 +7,8 @@ from code42cli.click_ext.options import incompatible_with from code42cli.click_ext.types import FileOrString +from code42cli.logger.enums import ServerProtocol +from code42cli.output_formats import SendToFileEventsOutputFormat def is_in_filter(filter_cls): @@ -140,3 +142,39 @@ def advanced_query_option(term, **kwargs): ) defaults.update(kwargs) return click.option("--advanced-query", **defaults) + + +def server_options(f): + hostname_arg = click.argument("hostname") + protocol_option = click.option( + "-p", + "--protocol", + type=click.Choice(ServerProtocol(), case_sensitive=False), + default=ServerProtocol.UDP, + help="Protocol used to send logs to server. " + "Use TLS for additional security. Defaults to UDP.", + ) + certs_option = click.option( + "--certs", type=str, help="A CA certificates-chain file for the TLS protocol." + ) + ignore_cert_validation = click.option( + "--ignore-cert-validation", + help="Set to skip CA certificate validation. " + "Incompatible with the 'certs' option.", + is_flag=True, + cls=incompatible_with(["certs"]), + ) + f = hostname_arg(f) + f = protocol_option(f) + f = certs_option(f) + f = ignore_cert_validation(f) + return f + + +send_to_format_options = click.option( + "-f", + "--format", + type=click.Choice(SendToFileEventsOutputFormat(), case_sensitive=False), + help="The output format of the result. Defaults to RAW-JSON format.", + default=SendToFileEventsOutputFormat.RAW, +) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 96a16581c..ac22512ad 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -1,4 +1,3 @@ -from _collections import OrderedDict from pprint import pformat import click @@ -8,48 +7,33 @@ from py42.sdk.queries.fileevents.filters.exposure_filter import ExposureType from py42.sdk.queries.fileevents.filters.file_filter import FileCategory -import code42cli.cmds.search.enums as enum import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt import code42cli.errors as errors import code42cli.options as opt from code42cli.click_ext.groups import OrderedGroup from code42cli.click_ext.options import incompatible_with +from code42cli.cmds.search import SendToCommand from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.cmds.search.extraction import handle_no_events -from code42cli.cmds.securitydata_output_formats import FileEventsOutputFormatter +from code42cli.cmds.search.options import send_to_format_options +from code42cli.cmds.search.options import server_options from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import limit_date_range -from code42cli.logger import get_logger_for_server from code42cli.options import format_option from code42cli.options import sdk_options -from code42cli.options import send_to_format_options -from code42cli.options import server_options +from code42cli.output_formats import FileEventsOutputFormat +from code42cli.output_formats import FileEventsOutputFormatter from code42cli.output_formats import OutputFormatter -SECURITY_DATA_KEYWORD = "file events" -_HEADER_KEYS_MAP = OrderedDict() -_HEADER_KEYS_MAP["name"] = "Name" -_HEADER_KEYS_MAP["id"] = "Id" - -SEARCH_DEFAULT_HEADER = OrderedDict() -SEARCH_DEFAULT_HEADER["fileName"] = "FileName" -SEARCH_DEFAULT_HEADER["filePath"] = "FilePath" -SEARCH_DEFAULT_HEADER["eventType"] = "Type" -SEARCH_DEFAULT_HEADER["eventTimestamp"] = "EventTimestamp" -SEARCH_DEFAULT_HEADER["fileCategory"] = "FileCategory" -SEARCH_DEFAULT_HEADER["fileSize"] = "FileSize" -SEARCH_DEFAULT_HEADER["fileOwner"] = "FileOwner" -SEARCH_DEFAULT_HEADER["md5Checksum"] = "MD5Checksum" -SEARCH_DEFAULT_HEADER["sha256Checksum"] = "SHA256Checksum" - +SECURITY_DATA_KEYWORD = "file events" file_events_format_option = click.option( "-f", "--format", - type=click.Choice(enum.FileEventsOutputFormat(), case_sensitive=False), + type=click.Choice(FileEventsOutputFormat(), case_sensitive=False), help="The output format of the result. Defaults to table format.", - default=enum.FileEventsOutputFormat.TABLE, + default=FileEventsOutputFormat.TABLE, ) exposure_type_option = click.option( "-t", @@ -140,22 +124,6 @@ cls=incompatible_with(["advanced_query", "type", "saved_search"]), help="Get all events including non-exposure events.", ) - - -def _get_saved_search_query(ctx, param, arg): - if arg is None: - return - query = ctx.obj.sdk.securitydata.savedsearches.get_query(arg) - return query - - -saved_search_option = click.option( - "--saved-search", - help="Get events from a saved search filter with the given ID.", - callback=_get_saved_search_query, - cls=incompatible_with("advanced_query"), -) - begin_option = opt.begin_option( SECURITY_DATA_KEYWORD, callback=lambda ctx, param, arg: convert_datetime_to_timestamp( @@ -169,6 +137,39 @@ def _get_saved_search_query(ctx, param, arg): advanced_query_option = searchopt.advanced_query_option(SECURITY_DATA_KEYWORD) +def _get_saved_search_option(): + def _get_saved_search_query(ctx, param, arg): + if arg is None: + return + query = ctx.obj.sdk.securitydata.savedsearches.get_query(arg) + return query + + return click.option( + "--saved-search", + help="Get events from a saved search filter with the given ID.", + callback=_get_saved_search_query, + cls=incompatible_with("advanced_query"), + ) + + +def _create_header_keys_map(): + return {"name": "Name", "id": "Id"} + + +def _create_search_header_map(): + return { + "fileName": "FileName", + "filePath": "FilePath", + "eventType": "Type", + "eventTimestamp": "EventTimestamp", + "fileCategory": "FileCategory", + "fileSize": "FileSize", + "fileOwner": "FileOwner", + "md5Checksum": "MD5Checksum", + "sha256Checksum": "SHA256Checksum", + } + + def search_options(f): f = checkpoint_option(f) f = advanced_query_option(f) @@ -190,7 +191,7 @@ def file_event_options(f): f = process_owner_option(f) f = tab_url_option(f) f = include_non_exposure_option(f) - f = saved_search_option(f) + f = _get_saved_search_option()(f) return f @@ -210,24 +211,6 @@ def clear_checkpoint(state, checkpoint_name): _get_file_event_cursor_store(state.profile.name).delete(checkpoint_name) -def _call_extractor( - state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs -): - if advanced_query: - state.search_filters = advanced_query - extractor = _get_file_event_extractor(state.sdk, handlers) - extractor.use_or_query = or_query - extractor.or_query_exempt_filters.append(f.ExposureType.exists()) - if saved_search: - extractor.extract(*saved_search._filter_group_list) - else: - if begin or end: - state.search_filters.append( - ext.create_time_range_filter(f.EventTimestamp, begin, end) - ) - extractor.extract(*state.search_filters) - - @security_data.command() @file_event_options @search_options @@ -256,13 +239,10 @@ def search( ): """Search for file events.""" output_header = ext.try_get_default_header( - include_all, SEARCH_DEFAULT_HEADER, format + include_all, _create_search_header_map(), format ) - formatter = FileEventsOutputFormatter(format, output_header) - cursor = ( - _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None - ) + cursor = _get_cursor(state, use_checkpoint) handlers = ext.create_handlers( state.sdk, FileEventExtractor, @@ -271,12 +251,10 @@ def search( formatter=formatter, force_pager=include_all, ) - _call_extractor( + _extract( state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs ) - handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) - @security_data.group(cls=OrderedGroup) @sdk_options() @@ -290,7 +268,7 @@ def saved_search(state): @sdk_options() def _list(state, format=None): """List available saved searches.""" - formatter = OutputFormatter(format, _HEADER_KEYS_MAP) + formatter = OutputFormatter(format, _create_header_keys_map()) response = state.sdk.securitydata.savedsearches.get() saved_searches = response["searches"] if saved_searches: @@ -306,7 +284,7 @@ def show(state, search_id): echo(pformat(response["searches"])) -@security_data.command() +@security_data.command(cls=SendToCommand) @file_event_options @search_options @click.option( @@ -325,38 +303,55 @@ def show(state, search_id): ) @send_to_format_options def send_to( - state, - format, - hostname, - protocol, - begin, - end, - advanced_query, - use_checkpoint, - saved_search, - or_query, - **kwargs, + state, begin, end, advanced_query, use_checkpoint, saved_search, or_query, **kwargs, ): """Send events to the given server address. HOSTNAME format: address:port where port is optional and defaults to 514. """ - logger = get_logger_for_server(hostname, protocol, format) - cursor = ( - _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None - ) + cursor = _get_cursor(state, use_checkpoint) handlers = ext.create_send_to_handlers( - state.sdk, FileEventExtractor, cursor, use_checkpoint, logger + state.sdk, FileEventExtractor, cursor, use_checkpoint, state.logger ) - _call_extractor( + _extract( state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs ) - handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) def _get_file_event_extractor(sdk, handlers): return FileEventExtractor(sdk, handlers) +def _get_cursor(state, use_checkpoint): + return _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None + + def _get_file_event_cursor_store(profile_name): return FileEventCursorStore(profile_name) + + +def _extract( + state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs +): + _call_extractor( + state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs + ) + handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) + + +def _call_extractor( + state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs +): + if advanced_query: + state.search_filters = advanced_query + extractor = _get_file_event_extractor(state.sdk, handlers) + extractor.use_or_query = or_query + extractor.or_query_exempt_filters.append(f.ExposureType.exists()) + if saved_search: + extractor.extract(*saved_search._filter_group_list) + else: + if begin or end: + state.search_filters.append( + ext.create_time_range_filter(f.EventTimestamp, begin, end) + ) + extractor.extract(*state.search_filters) diff --git a/src/code42cli/cmds/securitydata_output_formats.py b/src/code42cli/cmds/securitydata_output_formats.py deleted file mode 100644 index f42a02bef..000000000 --- a/src/code42cli/cmds/securitydata_output_formats.py +++ /dev/null @@ -1,106 +0,0 @@ -from datetime import datetime - -from c42eventextractor.logging.formatters import CEF_TEMPLATE -from c42eventextractor.logging.formatters import CEF_TIMESTAMP_FIELDS -from c42eventextractor.maps import CEF_CUSTOM_FIELD_NAME_MAP -from c42eventextractor.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP -from c42eventextractor.maps import JSON_TO_CEF_MAP - -import code42cli.cmds.search.enums as enum -from code42cli.output_formats import CEF_DEFAULT_PRODUCT_NAME -from code42cli.output_formats import CEF_DEFAULT_SEVERITY_LEVEL -from code42cli.output_formats import OutputFormatter - - -class FileEventsOutputFormatter(OutputFormatter): - def __init__(self, output_format, header=None): - output_format = ( - output_format.upper() - if output_format - else enum.FileEventsOutputFormat.TABLE - ) - super().__init__(output_format, header) - if output_format == enum.FileEventsOutputFormat.CEF: - self._format_func = to_cef - - -def to_cef(output): - """Output is a single record""" - return "{}\n".format(_convert_event_to_cef(output)) - - -def _convert_event_to_cef(event): - kvp_list = { - JSON_TO_CEF_MAP[key]: event[key] - for key in event - if key in JSON_TO_CEF_MAP and (event[key] is not None and event[key] != []) - } - - extension = " ".join(_format_cef_kvp(key, kvp_list[key]) for key in kvp_list) - event_name = event.get("eventType", "UNKNOWN") - signature_id = FILE_EVENT_TO_SIGNATURE_ID_MAP.get(event_name, "C42000") - - cef_log = CEF_TEMPLATE.format( - productName=CEF_DEFAULT_PRODUCT_NAME, - signatureID=signature_id, - eventName=event_name, - severity=CEF_DEFAULT_SEVERITY_LEVEL, - extension=extension, - ) - return cef_log - - -def _format_cef_kvp(cef_field_key, cef_field_value): - if cef_field_key + "Label" in CEF_CUSTOM_FIELD_NAME_MAP: - return _format_custom_cef_kvp(cef_field_key, cef_field_value) - - cef_field_value = _handle_nested_json_fields(cef_field_key, cef_field_value) - if isinstance(cef_field_value, list): - cef_field_value = _convert_list_to_csv(cef_field_value) - elif cef_field_key in CEF_TIMESTAMP_FIELDS: - cef_field_value = _convert_file_event_timestamp_to_cef_timestamp( - cef_field_value - ) - return "{}={}".format(cef_field_key, cef_field_value) - - -def _format_custom_cef_kvp(custom_cef_field_key, custom_cef_field_value): - custom_cef_label_key = "{}Label".format(custom_cef_field_key) - custom_cef_label_value = CEF_CUSTOM_FIELD_NAME_MAP[custom_cef_label_key] - return "{}={} {}={}".format( - custom_cef_field_key, - custom_cef_field_value, - custom_cef_label_key, - custom_cef_label_value, - ) - - -def _handle_nested_json_fields(cef_field_key, cef_field_value): - result = [] - if cef_field_key == "duser": - result = [ - item["cloudUsername"] for item in cef_field_value if type(item) is dict - ] - - return result or cef_field_value - - -def _convert_list_to_csv(_list): - value = ",".join([val for val in _list]) - return value - - -def _convert_file_event_timestamp_to_cef_timestamp(timestamp_value): - try: - _datetime = datetime.strptime(timestamp_value, "%Y-%m-%dT%H:%M:%S.%fZ") - except ValueError: - _datetime = datetime.strptime(timestamp_value, "%Y-%m-%dT%H:%M:%SZ") - value = "{:.0f}".format(_datetime_to_ms_since_epoch(_datetime)) - return value - - -def _datetime_to_ms_since_epoch(_datetime): - epoch = datetime.utcfromtimestamp(0) - total_seconds = (_datetime - epoch).total_seconds() - # total_seconds will be in decimals (millisecond precision) - return total_seconds * 1000 diff --git a/src/code42cli/logger.py b/src/code42cli/logger/__init__.py similarity index 73% rename from src/code42cli/logger.py rename to src/code42cli/logger/__init__.py index 785368a89..3bff40ae2 100644 --- a/src/code42cli/logger.py +++ b/src/code42cli/logger/__init__.py @@ -1,17 +1,14 @@ import logging import os -import sys import traceback from logging.handlers import RotatingFileHandler from threading import Lock -from c42eventextractor.logging.formatters import FileEventDictToCEFFormatter -from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter -from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter -from c42eventextractor.logging.handlers import NoPrioritySysLogHandlerWrapper -from click.exceptions import ClickException - -from code42cli.cmds.search.enums import FileEventsOutputFormat +from code42cli.logger.formatters import FileEventDictToCEFFormatter +from code42cli.logger.formatters import FileEventDictToJSONFormatter +from code42cli.logger.formatters import FileEventDictToRawJSONFormatter +from code42cli.logger.handlers import NoPrioritySysLogHandler +from code42cli.output_formats import FileEventsOutputFormat from code42cli.util import get_url_parts from code42cli.util import get_user_project_path @@ -37,42 +34,26 @@ def _init_logger(logger, handler, output_format): return add_handler_to_logger(logger, handler, formatter) -def handleError(record): - """Override logger's `handleError` method to exit if an exception is raised while trying to - log, otherwise it would continue to gather and process events if the connection breaks but send - them nowhere. - """ - t, v, tb = sys.exc_info() - if t == BrokenPipeError: - raise ClickException("Network connection broken while sending results.") - - -def get_logger_for_server(hostname, protocol, output_format): +def get_logger_for_server(hostname, protocol, output_format, certs): """Gets the logger that sends logs to a server for the given format. Args: hostname: The hostname of the server. It may include the port. protocol: The transfer protocol for sending logs. output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. + certs: Use for passing SSL/TLS certificates when connecting to the server. """ logger = logging.getLogger("code42_syslog_{}".format(output_format.lower())) if logger_has_handlers(logger): return logger with logger_deps_lock: + url_parts = get_url_parts(hostname) + hostname = url_parts[0] + port = url_parts[1] or 514 if not logger_has_handlers(logger): - url_parts = get_url_parts(hostname) - port = url_parts[1] or 514 - try: - handler = NoPrioritySysLogHandlerWrapper( - url_parts[0], port=port, protocol=protocol - ).handler - except Exception as e: - raise Exception( - "Unable to connect {}. Failed with error {}".format( - hostname, str(e) - ) - ) + handler = NoPrioritySysLogHandler(hostname, port, protocol, certs) + handler.connect_socket() return _init_logger(logger, handler, output_format) return logger diff --git a/src/code42cli/logger/enums.py b/src/code42cli/logger/enums.py new file mode 100644 index 000000000..7f314cea3 --- /dev/null +++ b/src/code42cli/logger/enums.py @@ -0,0 +1,7 @@ +class ServerProtocol: + TCP = "TCP" + UDP = "UDP" + TLS_TCP = "TLS-TCP" + + def __iter__(self): + return iter([self.TCP, self.UDP, self.TLS_TCP]) diff --git a/src/code42cli/logger/formatters.py b/src/code42cli/logger/formatters.py new file mode 100644 index 000000000..85d4e83c8 --- /dev/null +++ b/src/code42cli/logger/formatters.py @@ -0,0 +1,139 @@ +import json +from datetime import datetime +from logging import Formatter + +from code42cli.maps import CEF_CUSTOM_FIELD_NAME_MAP +from code42cli.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP +from code42cli.maps import JSON_TO_CEF_MAP + +CEF_TEMPLATE = ( + "CEF:0|Code42|{productName}|1|{signatureID}|{eventName}|{severity}|{extension}" +) +CEF_TIMESTAMP_FIELDS = ["end", "fileCreateTime", "fileModificationTime", "rt"] + + +class FileEventDictToCEFFormatter(Formatter): + """Formats file event dicts into CEF format. Attach to a logger via `setFormatter` to use. + Args: + default_product_name: The default value to use in the product name segment of the CEF message. + default_severity_level: The default integer between 1 and 10 to assign to the severity segment of the CEF message. + """ + + def __init__( + self, + default_product_name="Advanced Exfiltration Detection", + default_severity_level="5", + ): + super().__init__() + self._default_product_name = default_product_name + self._default_severity_level = default_severity_level + + def format(self, record): + """ + Args: + record (LogRecord): `record.msg` must be a `dict`. + """ + file_event_dict = record.msg + # security events must convert to file event dict format before calling this. + ext, evt, sig_id = map_event_to_cef(file_event_dict) + cef_log = CEF_TEMPLATE.format( + productName=self._default_product_name, + signatureID=sig_id, + eventName=evt, + severity=self._default_severity_level, + extension=ext, + ) + return cef_log + + +class FileEventDictToJSONFormatter(Formatter): + """Formats file event dicts into JSON format. Attach to a logger via `setFormatter` to use. + Items in the dictionary whose values are `None`, empty string, or empty lists will be excluded + from the JSON conversion. + """ + + def format(self, record): + """ + Args: + record (LogRecord): `record.msg` must be a `dict`. + """ + file_event_dict = record.msg + file_event_dict = { + key: file_event_dict[key] + for key in file_event_dict + if file_event_dict[key] or file_event_dict[key] == 0 + } + return json.dumps(file_event_dict) + + +class FileEventDictToRawJSONFormatter(Formatter): + """Formats file event dicts into JSON format. Attach to a logger via `setFormatter` to use.""" + + def format(self, record): + return json.dumps(record.msg) + + +def _format_cef_kvp(cef_field_key, cef_field_value): + if cef_field_key + "Label" in CEF_CUSTOM_FIELD_NAME_MAP: + return _format_custom_cef_kvp(cef_field_key, cef_field_value) + + cef_field_value = _handle_nested_json_fields(cef_field_key, cef_field_value) + if isinstance(cef_field_value, list): + cef_field_value = _convert_list_to_csv(cef_field_value) + elif cef_field_key in CEF_TIMESTAMP_FIELDS: + cef_field_value = convert_file_event_timestamp_to_cef_timestamp(cef_field_value) + return "{}={}".format(cef_field_key, cef_field_value) + + +def _handle_nested_json_fields(cef_field_key, cef_field_value): + result = [] + if cef_field_key == "duser": + result = [ + item["cloudUsername"] for item in cef_field_value if type(item) is dict + ] + + return result or cef_field_value + + +def _format_custom_cef_kvp(custom_cef_field_key, custom_cef_field_value): + custom_cef_label_key = "{}Label".format(custom_cef_field_key) + custom_cef_label_value = CEF_CUSTOM_FIELD_NAME_MAP[custom_cef_label_key] + return "{}={} {}={}".format( + custom_cef_field_key, + custom_cef_field_value, + custom_cef_label_key, + custom_cef_label_value, + ) + + +def _convert_list_to_csv(_list): + value = ",".join([val for val in _list]) + return value + + +def convert_file_event_timestamp_to_cef_timestamp(timestamp_value): + try: + _datetime = datetime.strptime(timestamp_value, "%Y-%m-%dT%H:%M:%S.%fZ") + except ValueError: + _datetime = datetime.strptime(timestamp_value, "%Y-%m-%dT%H:%M:%SZ") + value = "{:.0f}".format(_datetime_to_ms_since_epoch(_datetime)) + return value + + +def _datetime_to_ms_since_epoch(_datetime): + epoch = datetime.utcfromtimestamp(0) + total_seconds = (_datetime - epoch).total_seconds() + # total_seconds will be in decimals (millisecond precision) + return total_seconds * 1000 + + +def map_event_to_cef(event): + kvp_list = { + JSON_TO_CEF_MAP[key]: event[key] + for key in event + if key in JSON_TO_CEF_MAP and (event[key] is not None and event[key] != []) + } + extension = " ".join(_format_cef_kvp(key, kvp_list[key]) for key in kvp_list) + event_name = event.get("eventType", "UNKNOWN") + signature_id = FILE_EVENT_TO_SIGNATURE_ID_MAP.get(event_name, "C42000") + return extension, event_name, signature_id diff --git a/src/code42cli/logger/handlers.py b/src/code42cli/logger/handlers.py new file mode 100644 index 000000000..d8fecea50 --- /dev/null +++ b/src/code42cli/logger/handlers.py @@ -0,0 +1,145 @@ +import logging +import socket +import ssl +import sys +from logging.handlers import SysLogHandler + +from code42cli.logger.enums import ServerProtocol + + +class SyslogServerNetworkConnectionError(Exception): + """An error raised when the connection is disrupted during logging.""" + + def __init__(self): + super().__init__("Network connection broken while sending results.") + + +class NoPrioritySysLogHandler(SysLogHandler): + """ + Overrides the default implementation of SysLogHandler to not send a `` at the + beginning of the message. Most CEF consumers seem to not expect the `` to be + present in CEF messages. Attach to a logger via `.addHandler` to use. + + `self.socket` is lazily loaded for testing purposes, so the connection does not get + made for TCP/TLS until the first log record is about to be transmitted. + + Args: + hostname: The hostname of the syslog server to send log messages to. + port: The port of the syslog server to send log messages to. + protocol: The protocol over which to submit syslog messages. Accepts TCP, UDP, or TLS. + certs: Certs to specify when using TLS-TCP for the `protocol` argument. Use "ignore" for + ssl.CERT_NONE (ignoring certificate validation). + """ + + def __init__(self, hostname, port, protocol, certs): + self._hostname = hostname + self._port = port + self._protocol = protocol + self._certs = certs + self.address = (hostname, port) + logging.Handler.__init__(self) + self.socktype = _try_get_socket_type_from_protocol(protocol) + self.socket = None + + @property + def _wrap_socket(self): + return self._protocol == ServerProtocol.TLS_TCP + + def connect_socket(self): + """Call to initialize the socket. If using TCP/TLS, it will also establish the connection. + """ + if not self.socket: + self.socket = self._create_socket(self._hostname, self._port, self._certs) + + def _create_socket(self, hostname, port, certs): + socket_info = self._get_socket_address_info(hostname, port) + address_family, sock_type, proto, _, sa = socket_info + sock = None + try: + sock = socket.socket(address_family, sock_type, proto) + if self._wrap_socket: + sock = _wrap_socket_for_ssl(sock, certs, hostname) + if sock_type == socket.SOCK_STREAM: + sock = _connect_socket(sock, sa) + return sock + except Exception as exc: + if sock is not None: + sock.close() + raise exc + + def _get_socket_address_info(self, hostname, port): + info = socket.getaddrinfo(hostname, port, 0, self.socktype) + if not info: + raise OSError("getaddrinfo() returns an empty list") + return info[0] + + def emit(self, record): + try: + self._send_record(record) + except Exception: + self.handleError(record) + + def handleError(self, record): + """Override logger's `handleError` method to exit if an exception is raised while trying to + log, otherwise it would continue to gather and process events if the connection breaks but send + them nowhere. + """ + t, _, _ = sys.exc_info() + if t == BrokenPipeError: + raise SyslogServerNetworkConnectionError() + super().handleError(record) + + def _send_record(self, record): + formatted_record = self.format(record) + msg = formatted_record + "\n" + msg = msg.encode("utf-8") + if self.socktype == socket.SOCK_DGRAM: + self.socket.sendto(msg, self.address) + else: + self.socket.sendall(msg) + + def close(self): + if self._wrap_socket: + self.socket.unwrap() + self.socket.close() + logging.Handler.close(self) + + +def _wrap_socket_for_ssl(sock, certs, hostname): + do_ignore_certs = certs and certs.lower() == "ignore" + if do_ignore_certs: + certs = None + context = ssl.create_default_context(cafile=certs) + if do_ignore_certs: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context.wrap_socket(sock, server_hostname=hostname) + + +def _connect_socket(sock, sa): + sock.settimeout(10) + sock.connect(sa) + # Set timeout back to None for 'blocking' mode, required for `sendall()`. + sock.settimeout(None) + return sock + + +def _try_get_socket_type_from_protocol(protocol): + socket_type = _get_socket_type_from_protocol(protocol) + if socket_type is None: + _raise_socket_type_error(protocol) + return socket_type + + +def _get_socket_type_from_protocol(protocol): + if protocol in [ServerProtocol.TCP, ServerProtocol.TLS_TCP]: + return socket.SOCK_STREAM + elif protocol == ServerProtocol.UDP: + return socket.SOCK_DGRAM + + +def _raise_socket_type_error(protocol): + msg = "Could not determine socket type. Expected one of {}, but got {}.".format( + list(ServerProtocol()), protocol + ) + raise ValueError(msg) diff --git a/src/code42cli/maps.py b/src/code42cli/maps.py new file mode 100644 index 000000000..7cf2891aa --- /dev/null +++ b/src/code42cli/maps.py @@ -0,0 +1,52 @@ +JSON_TO_CEF_MAP = { + "actor": "suser", + "cloudDriveId": "aid", + "createTimestamp": "fileCreateTime", + "deviceUid": "deviceExternalId", + "deviceUserName": "suser", + "domainName": "dvchost", + "emailRecipients": "duser", + "emailSender": "suser", + "eventId": "externalId", + "eventTimestamp": "end", + "exposure": "reason", + "fileCategory": "fileType", + "fileName": "fname", + "filePath": "filePath", + "fileSize": "fsize", + "insertionTimestamp": "rt", + "md5Checksum": "fileHash", + "modifyTimestamp": "fileModificationTime", + "osHostName": "shost", + "processName": "sproc", + "processOwner": "spriv", + "publicIpAddress": "src", + "removableMediaBusType": "cs1", + "removableMediaCapacity": "cn1", + "removableMediaName": "cs3", + "removableMediaSerialNumber": "cs4", + "removableMediaVendor": "cs2", + "sharedWith": "duser", + "source": "sourceServiceName", + "syncDestination": "destinationServiceName", + "tabUrl": "request", + "url": "filePath", + "userUid": "suid", + "windowTitle": "requestClientApplication", +} + +CEF_CUSTOM_FIELD_NAME_MAP = { + "cn1Label": "Code42AEDRemovableMediaCapacity", + "cs1Label": "Code42AEDRemovableMediaBusType", + "cs2Label": "Code42AEDRemovableMediaVendor", + "cs3Label": "Code42AEDRemovableMediaName", + "cs4Label": "Code42AEDRemovableMediaSerialNumber", +} + +FILE_EVENT_TO_SIGNATURE_ID_MAP = { + "CREATED": "C42200", + "MODIFIED": "C42201", + "DELETED": "C42202", + "READ_BY_APP": "C42203", + "EMAILED": "C42204", +} diff --git a/src/code42cli/options.py b/src/code42cli/options.py index bea3f03c2..ca4302028 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -1,13 +1,13 @@ import click from code42cli.click_ext.types import MagicDate -from code42cli.cmds.search.enums import ServerProtocol from code42cli.cmds.search.options import AdvancedQueryAndSavedSearchIncompatible from code42cli.cmds.search.options import BeginOption from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import round_datetime_to_day_end from code42cli.date_helper import round_datetime_to_day_start from code42cli.errors import Code42CLIError +from code42cli.logger.enums import ServerProtocol from code42cli.output_formats import OutputFormat from code42cli.output_formats import SendToFileEventsOutputFormat from code42cli.profile import get_profile diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 90a8638b1..b327f362c 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -5,6 +5,8 @@ import click from pandas import DataFrame +from code42cli.logger.formatters import CEF_TEMPLATE +from code42cli.logger.formatters import map_event_to_cef from code42cli.util import find_format_width from code42cli.util import format_to_table @@ -149,3 +151,37 @@ def to_formatted_json(output): """Output is a single record""" json_str = "{}\n".format(json.dumps(output, indent=4)) return json_str + + +class FileEventsOutputFormat(OutputFormat): + CEF = "CEF" + + def __iter__(self): + return iter([self.TABLE, self.CSV, self.JSON, self.RAW, self.CEF]) + + +class FileEventsOutputFormatter(OutputFormatter): + def __init__(self, output_format, header=None): + output_format = ( + output_format.upper() if output_format else FileEventsOutputFormat.TABLE + ) + super().__init__(output_format, header) + if output_format == FileEventsOutputFormat.CEF: + self._format_func = to_cef + + +def to_cef(output): + """Output is a single record""" + return "{}\n".format(_convert_event_to_cef(output)) + + +def _convert_event_to_cef(event): + ext, evt, sig_id = map_event_to_cef(event) + cef_log = CEF_TEMPLATE.format( + productName=CEF_DEFAULT_PRODUCT_NAME, + signatureID=sig_id, + eventName=evt, + severity=CEF_DEFAULT_SEVERITY_LEVEL, + extension=ext, + ) + return cef_log diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 98e26c7f3..ea6397db4 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -54,9 +54,7 @@ def cli_logger(mocker): @pytest.fixture def event_extractor_logger(mocker): - mock = mocker.patch( - "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper" - ) + mock = mocker.patch("code42cli.logger.handlers.NoPrioritySysLogHandler") mock.emit.return_value = mocker.MagicMock() return mock @@ -119,3 +117,9 @@ def gen(*args, **kwargs): yield Py42Response(response) return gen + + +def get_mark_for_search_and_send_to(command_group): + search_cmd = [command_group, "search"] + send_to_cmd = [command_group, "send-to", "0.0.0.0"] + return pytest.mark.parametrize("command", (search_cmd, send_to_cmd)) diff --git a/tests/cmds/search/test_enums.py b/tests/cmds/search/test_enums.py deleted file mode 100644 index f304ce583..000000000 --- a/tests/cmds/search/test_enums.py +++ /dev/null @@ -1,8 +0,0 @@ -from code42cli.cmds.search.enums import FileEventsOutputFormat - - -def test_security_data_output_format_has_expected_options(): - options = FileEventsOutputFormat() - actual = list(options) - expected = ["CEF", "CSV", "RAW-JSON", "JSON", "TABLE"] - assert set(actual) == set(expected) diff --git a/tests/cmds/search/test_init.py b/tests/cmds/search/test_init.py new file mode 100644 index 000000000..01b689984 --- /dev/null +++ b/tests/cmds/search/test_init.py @@ -0,0 +1,55 @@ +import pytest + +from code42cli.cmds.search import _try_get_logger_for_server +from code42cli.errors import Code42CLIError +from code42cli.logger.enums import ServerProtocol +from code42cli.output_formats import SendToFileEventsOutputFormat + + +_TEST_ERROR_MESSAGE = "TEST ERROR MESSAGE" +_TEST_HOST = "example.com" +_TEST_CERTS = "./certs.pem" + + +@pytest.fixture +def patched_get_logger_method(mocker): + return mocker.patch("code42cli.cmds.search.get_logger_for_server") + + +@pytest.fixture +def errored_logger(patched_get_logger_method): + patched_get_logger_method.side_effect = Exception(_TEST_ERROR_MESSAGE) + + +def test_try_get_logger_for_server_calls_get_logger_for_server( + patched_get_logger_method, +): + _try_get_logger_for_server( + _TEST_HOST, + ServerProtocol.TLS_TCP, + SendToFileEventsOutputFormat.CEF, + _TEST_CERTS, + ) + patched_get_logger_method.assert_called_once_with( + _TEST_HOST, + ServerProtocol.TLS_TCP, + SendToFileEventsOutputFormat.CEF, + _TEST_CERTS, + ) + + +def test_try_get_logger_for_server_when_exception_raised_raises_code42_cli_error( + errored_logger, +): + with pytest.raises(Code42CLIError) as err: + _try_get_logger_for_server( + _TEST_HOST, + ServerProtocol.TCP, + SendToFileEventsOutputFormat.RAW, + _TEST_CERTS, + ) + + assert ( + str(err.value) + == f"Unable to connect to example.com. Failed with error: {_TEST_ERROR_MESSAGE}." + ) diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index bb8eb5b2a..0a3e4f50c 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -14,7 +14,7 @@ TEST_RULE_ID = "rule-id" TEST_USER_ID = "test-user-id" -TEST_USERNAME = "test@code42.com" +TEST_USERNAME = "test@example.com" TEST_SOURCE = "rule source" TEST_EMPTY_RULE_RESPONSE = {"ruleMetadata": []} diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 4f12c058f..67bdaf9ad 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -5,21 +5,20 @@ from c42eventextractor.extractors import AlertExtractor from tests.cmds.conftest import filter_term_is_in_call_args from tests.cmds.conftest import get_filter_value_from_json +from tests.cmds.conftest import get_mark_for_search_and_send_to from tests.conftest import get_test_date_str from code42cli import PRODUCT_NAME from code42cli.cmds.search import extraction from code42cli.cmds.search.cursor_store import AlertCursorStore +from code42cli.logger.enums import ServerProtocol from code42cli.main import cli BEGIN_TIMESTAMP = 1577858400.0 END_TIMESTAMP = 1580450400.0 CURSOR_TIMESTAMP = 1579500000.0 - - ALERT_SUMMARY_LIST = [{"id": i} for i in range(20)] - ALERT_DETAIL_RESULT = [ { "alerts": [ @@ -82,7 +81,6 @@ ] }, ] - SORTED_ALERT_DETAILS = [ {"id": 12, "createdAt": "2020-01-20"}, {"id": 2, "createdAt": "2020-01-19"}, @@ -105,7 +103,6 @@ {"id": 13, "createdAt": "2020-01-02"}, {"id": 3, "createdAt": "2020-01-01"}, ] - ADVANCED_QUERY_VALUES = { "state_1": "OPEN", "state_2": "PENDING", @@ -184,6 +181,27 @@ }}""".format( **ADVANCED_QUERY_VALUES ) +advanced_query_incompat_test_params = pytest.mark.parametrize( + "arg", + [ + ("--begin", "1d"), + ("--end", "1d"), + ("--severity", "HIGH"), + ("--actor", "test"), + ("--actor-contains", "test"), + ("--exclude-actor", "test"), + ("--exclude-actor-contains", "test"), + ("--rule-name", "test"), + ("--exclude-rule-name", "test"), + ("--rule-id", "test"), + ("--exclude-rule-id", "test"), + ("--rule-type", "FedEndpointExfiltration"), + ("--exclude-rule-type", "FedEndpointExfiltration"), + ("--description", "test"), + ("--state", "OPEN"), + ], +) +search_and_send_to_test = get_mark_for_search_and_send_to("alerts") @pytest.fixture @@ -227,13 +245,17 @@ def alert_extract_func(mocker): return mocker.patch("{}.cmds.alerts._extract".format(PRODUCT_NAME)) -def test_search_when_advanced_query_passed_as_json_string_builds_expected_query( - cli_state, alert_extractor, runner +@pytest.fixture +def send_to_logger_factory(mocker): + return mocker.patch("code42cli.cmds.search._try_get_logger_for_server") + + +@search_and_send_to_test +def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_expected_query( + cli_state, alert_extractor, runner, command ): runner.invoke( - cli, - ["alerts", "search", "--advanced-query", ADVANCED_QUERY_JSON], - obj=cli_state, + cli, [*command, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state, ) passed_filter_groups = alert_extractor.extract.call_args[0] expected_actor_filter = f.Actor.contains(ADVANCED_QUERY_VALUES["actor"]) @@ -257,35 +279,17 @@ def test_search_when_advanced_query_passed_as_json_string_builds_expected_query( assert expected_rule_id_filter in passed_filter_groups -def test_search_without_advanced_query_uses_only_the_extract_method( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_without_advanced_query_uses_only_the_extract_method( + cli_state, alert_extractor, runner, command ): - runner.invoke(cli, ["alerts", "search", "--begin", "1d"], obj=cli_state) + runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) assert alert_extractor.extract.call_count == 1 assert alert_extractor.extract_advanced.call_count == 0 -@pytest.mark.parametrize( - "arg", - [ - ("--begin", "1d"), - ("--end", "1d"), - ("--severity", "HIGH"), - ("--actor", "test"), - ("--actor-contains", "test"), - ("--exclude-actor", "test"), - ("--exclude-actor-contains", "test"), - ("--rule-name", "test"), - ("--exclude-rule-name", "test"), - ("--rule-id", "test"), - ("--exclude-rule-id", "test"), - ("--rule-type", "FedEndpointExfiltration"), - ("--exclude-rule-type", "FedEndpointExfiltration"), - ("--description", "test"), - ("--state", "OPEN"), - ], -) +@advanced_query_incompat_test_params def test_search_with_advanced_query_and_incompatible_argument_errors( arg, cli_state, runner ): @@ -299,16 +303,29 @@ def test_search_with_advanced_query_and_incompatible_argument_errors( assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output -def test_search_when_given_begin_and_end_dates_uses_expected_query( - cli_state, alert_extractor, runner +@advanced_query_incompat_test_params +def test_send_to_with_advanced_query_and_incompatible_argument_errors( + arg, cli_state, runner +): + + result = runner.invoke( + cli, + ["alerts", "send-to", "0.0.0.0", "--advanced-query", ADVANCED_QUERY_JSON, *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( + cli_state, alert_extractor, runner, command ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) runner.invoke( - cli, - ["alerts", "search", "--begin", begin_date, "--end", end_date], - obj=cli_state, + cli, [*command, "--begin", begin_date, "--end", end_date], obj=cli_state, ) filters = alert_extractor.extract.call_args[0][0] actual_begin = get_filter_value_from_json(filters, filter_index=0) @@ -319,8 +336,9 @@ def test_search_when_given_begin_and_end_dates_uses_expected_query( assert actual_end == expected_end +@search_and_send_to_test def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( - cli_state, alert_extractor, runner + cli_state, alert_extractor, runner, command ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) @@ -328,8 +346,7 @@ def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( runner.invoke( cli, [ - "alerts", - "search", + *command, "--begin", "{} {}".format(begin_date, time), "--end", @@ -346,14 +363,13 @@ def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( assert actual_end == expected_end +@search_and_send_to_test def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_query( - cli_state, alert_extractor, runner + cli_state, alert_extractor, runner, command ): date = get_test_date_str(days_ago=89) time = "15:33" - runner.invoke( - cli, ["alerts", "search", "--begin", "{} {}".format(date, time)], obj=cli_state - ) + runner.invoke(cli, [*command, "--begin", "{} {}".format(date, time)], obj=cli_state) actual = get_filter_value_from_json( alert_extractor.extract.call_args[0][0], filter_index=0 ) @@ -361,22 +377,16 @@ def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_que assert actual == expected -def test_search_when_given_end_date_and_time_uses_expected_query( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_end_date_and_time_uses_expected_query( + cli_state, alert_extractor, runner, command ): begin_date = get_test_date_str(days_ago=10) end_date = get_test_date_str(days_ago=1) time = "15:33" runner.invoke( cli, - [ - "alerts", - "search", - "--begin", - begin_date, - "--end", - "{} {}".format(end_date, time), - ], + [*command, "--begin", begin_date, "--end", "{} {}".format(end_date, time)], obj=cli_state, ) actual = get_filter_value_from_json( @@ -386,34 +396,35 @@ def test_search_when_given_end_date_and_time_uses_expected_query( assert actual == expected -def test_search_when_given_begin_date_more_than_ninety_days_back_errors( - cli_state, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_date_more_than_ninety_days_back_errors( + cli_state, runner, command ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - result = runner.invoke( - cli, ["alerts", "search", "--begin", begin_date], obj=cli_state - ) + result = runner.invoke(cli, [*command, "--begin", begin_date], obj=cli_state) assert "must be within 90 days" in result.output assert result.exit_code == 2 -def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - cli_state, alert_cursor_with_checkpoint, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + cli_state, alert_cursor_with_checkpoint, alert_extractor, runner, command ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" runner.invoke( cli, - ["alerts", "search", "--begin", begin_date, "--use-checkpoint", "test"], + [*command, "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state, ) assert not filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) -def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( + cli_state, alert_extractor, runner, command ): begin_date = get_test_date_str(days_ago=1) - runner.invoke(cli, ["alerts", "search", "--begin", begin_date], obj=cli_state) + runner.invoke(cli, [*command, "--begin", begin_date], obj=cli_state) actual_ts = get_filter_value_from_json( alert_extractor.extract.call_args[0][0], filter_index=0 ) @@ -422,37 +433,25 @@ def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_u assert filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) -def test_search_when_end_date_is_before_begin_date_causes_exit(cli_state, runner): +@search_and_send_to_test +def test_search_and_send_to_when_end_date_is_before_begin_date_causes_exit( + cli_state, runner, command +): begin_date = get_test_date_str(days_ago=1) end_date = get_test_date_str(days_ago=3) result = runner.invoke( - cli, - ["alerts", "search", "--begin", begin_date, "--end", end_date], - obj=cli_state, + cli, [*command, "--begin", begin_date, "--end", end_date], obj=cli_state, ) assert result.exit_code == 2 assert "'--begin': cannot be after --end date" in result.output -def test_get_alert_details_batches_results_according_to_batch_size(sdk): - extraction._ALERT_DETAIL_BATCH_SIZE = 2 - sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT - extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) - assert sdk.alerts.get_details.call_count == 10 - - -def test_get_alert_details_sorts_results_by_date(sdk): - extraction._ALERT_DETAIL_BATCH_SIZE = 2 - sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT - results = extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) - assert results == SORTED_ALERT_DETAILS - - -def test_search_with_only_begin_calls_extract_with_expected_filters( - cli_state, alert_extractor, begin_option, runner +@search_and_send_to_test +def test_search_and_send_to_with_only_begin_calls_extract_with_expected_filters( + cli_state, alert_extractor, begin_option, runner, command ): - result = runner.invoke(cli, ["alerts", "search", "--begin", "1d"], obj=cli_state) - assert result.exit_code == 0 + res = runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) + assert res.exit_code == 0 assert str( alert_extractor.extract.call_args[0][0] ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"createdAt", "value":"{}"}}]}}'.format( @@ -460,12 +459,11 @@ def test_search_with_only_begin_calls_extract_with_expected_filters( ) -def test_search_with_use_checkpoint_and_without_begin_and_without_stored_checkpoint_causes_expected_error( - cli_state, alert_cursor_without_checkpoint, runner +@search_and_send_to_test +def test_search_and_send_to_with_use_checkpoint_and_without_begin_and_without_stored_checkpoint_causes_expected_error( + cli_state, alert_cursor_without_checkpoint, runner, command ): - result = runner.invoke( - cli, ["alerts", "search", "--use-checkpoint", "test"], obj=cli_state - ) + result = runner.invoke(cli, [*command, "--use-checkpoint", "test"], obj=cli_state) assert result.exit_code == 2 assert ( "--begin date is required for --use-checkpoint when no checkpoint exists yet." @@ -473,32 +471,35 @@ def test_search_with_use_checkpoint_and_without_begin_and_without_stored_checkpo ) -def test_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( - cli_state, alert_extractor, begin_option, alert_cursor_without_checkpoint, runner, +@search_and_send_to_test +def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( + cli_state, + alert_extractor, + begin_option, + alert_cursor_without_checkpoint, + runner, + command, ): - result = runner.invoke( - cli, - ["alerts", "search", "--use-checkpoint", "test", "--begin", "1d"], - obj=cli_state, + res = runner.invoke( + cli, [*command, "--use-checkpoint", "test", "--begin", "1d"], obj=cli_state, ) - assert result.exit_code == 0 + assert res.exit_code == 0 assert len(alert_extractor.extract.call_args[0]) == 1 assert begin_option.expected_timestamp in str( alert_extractor.extract.call_args[0][0] ) -def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( - cli_state, alert_extractor, alert_cursor_with_checkpoint, runner +@search_and_send_to_test +def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( + cli_state, alert_extractor, alert_cursor_with_checkpoint, runner, command ): result = runner.invoke( - cli, - ["alerts", "search", "--use-checkpoint", "test", "--begin", "1h"], - obj=cli_state, + cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, ) assert result.exit_code == 0 - alert_extractor.extract.assert_called_with() + assert alert_extractor.extract.call_count == 1 assert ( "checkpoint of {} exists".format( alert_cursor_with_checkpoint.expected_timestamp @@ -507,140 +508,128 @@ def test_search_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_ca ) -def test_search_when_given_actor_is_uses_username_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_actor_is_uses_username_filter( + cli_state, alert_extractor, runner, command ): actor_name = "test.testerson" - runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--actor", actor_name], obj=cli_state + cli, [*command, "--begin", "1h", "--actor", actor_name], obj=cli_state ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.Actor.is_in([actor_name])) in filter_strings -def test_search_when_given_exclude_actor_uses_actor_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_exclude_actor_uses_actor_filter( + cli_state, alert_extractor, runner, command ): actor_name = "test.testerson" - runner.invoke( - cli, - ["alerts", "search", "--begin", "1h", "--exclude-actor", actor_name], - obj=cli_state, + cli, [*command, "--begin", "1h", "--exclude-actor", actor_name], obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.Actor.not_in([actor_name])) in filter_strings -def test_search_when_given_rule_name_uses_rule_name_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_rule_name_uses_rule_name_filter( + cli_state, alert_extractor, runner, command ): rule_name = "departing employee" - runner.invoke( - cli, - ["alerts", "search", "--begin", "1h", "--rule-name", rule_name], - obj=cli_state, + cli, [*command, "--begin", "1h", "--rule-name", rule_name], obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.RuleName.is_in([rule_name])) in filter_strings -def test_search_when_given_exclude_rule_name_uses_rule_name_not_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_exclude_rule_name_uses_rule_name_not_filter( + cli_state, alert_extractor, runner, command ): rule_name = "departing employee" - runner.invoke( cli, - ["alerts", "search", "--begin", "1h", "--exclude-rule-name", rule_name], + [*command, "--begin", "1h", "--exclude-rule-name", rule_name], obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.RuleName.not_in([rule_name])) in filter_strings -def test_search_when_given_rule_type_uses_rule_name_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_rule_type_uses_rule_name_filter( + cli_state, alert_extractor, runner, command ): rule_type = "FedEndpointExfiltration" - runner.invoke( - cli, - ["alerts", "search", "--begin", "1h", "--rule-type", rule_type], - obj=cli_state, + cli, [*command, "--begin", "1h", "--rule-type", rule_type], obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.RuleType.is_in([rule_type])) in filter_strings -def test_search_when_given_exclude_rule_type_uses_rule_name_not_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_exclude_rule_type_uses_rule_name_not_filter( + cli_state, alert_extractor, runner, command ): rule_type = "FedEndpointExfiltration" - runner.invoke( cli, - ["alerts", "search", "--begin", "1h", "--exclude-rule-type", rule_type], + [*command, "--begin", "1h", "--exclude-rule-type", rule_type], obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.RuleType.not_in([rule_type])) in filter_strings -def test_search_when_given_rule_id_uses_rule_name_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_rule_id_uses_rule_name_filter( + cli_state, alert_extractor, runner, command ): rule_id = "departing employee" - - runner.invoke( - cli, ["alerts", "search", "--begin", "1h", "--rule-id", rule_id], obj=cli_state - ) + runner.invoke(cli, [*command, "--begin", "1h", "--rule-id", rule_id], obj=cli_state) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.RuleId.is_in([rule_id])) in filter_strings -def test_search_when_given_exclude_rule_id_uses_rule_name_not_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_exclude_rule_id_uses_rule_name_not_filter( + cli_state, alert_extractor, runner, command ): rule_id = "departing employee" - runner.invoke( - cli, - ["alerts", "search", "--begin", "1h", "--exclude-rule-id", rule_id], - obj=cli_state, + cli, [*command, "--begin", "1h", "--exclude-rule-id", rule_id], obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.RuleId.not_in([rule_id])) in filter_strings -def test_search_when_given_description_uses_description_filter( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_description_uses_description_filter( + cli_state, alert_extractor, runner, command ): description = "test description" - runner.invoke( - cli, - ["alerts", "search", "--begin", "1h", "--description", description], - obj=cli_state, + cli, [*command, "--begin", "1h", "--description", description], obj=cli_state, ) filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] assert str(f.Description.contains(description)) in filter_strings -def test_search_when_given_multiple_search_args_uses_expected_filters( - cli_state, alert_extractor, runner +@search_and_send_to_test +def test_search_and_send_to_when_given_multiple_search_args_uses_expected_filters( + cli_state, alert_extractor, runner, command ): actor = "test.testerson@example.com" - exclude_actor = "flag.flagerson@code42.com" + exclude_actor = "flag.flagerson@example.com" rule_name = "departing employee" runner.invoke( cli, [ - "alerts", - "search", + *command, "--begin", "1h", "--actor", @@ -658,15 +647,17 @@ def test_search_when_given_multiple_search_args_uses_expected_filters( assert str(f.RuleName.is_in([rule_name])) in filter_strings -def test_search_with_or_query_flag_produces_expected_query(runner, cli_state): +@search_and_send_to_test +def test_search_and_send_to_with_or_query_flag_produces_expected_query( + runner, cli_state, command +): begin_date = get_test_date_str(days_ago=10) test_actor = "test@example.com" test_rule_type = "FedEndpointExfiltration" runner.invoke( cli, [ - "alerts", - "search", + *command, "--or-query", "--begin", begin_date, @@ -712,290 +703,50 @@ def test_search_with_or_query_flag_produces_expected_query(runner, cli_state): assert actual_query == expected_query -def test_send_to_makes_call_to_the_extract_method( - cli_state, alert_extractor, runner, event_extractor_logger -): - - runner.invoke( - cli, ["alerts", "send-to", "localhost", "--begin", "1d"], obj=cli_state - ) - assert alert_extractor.extract.call_count == 1 - assert alert_extractor.extract_advanced.call_count == 0 - - -def test_send_to_when_given_description_uses_description_filter( - cli_state, alert_extractor, runner -): - description = "test description" - - runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--description", description], - obj=cli_state, - ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.Description.contains(description)) in filter_strings - - @pytest.mark.parametrize( - "arg", - [ - ("--begin", "1d"), - ("--end", "1d"), - ("--severity", "HIGH"), - ("--actor", "test"), - ("--actor-contains", "test"), - ("--exclude-actor", "test"), - ("--exclude-actor-contains", "test"), - ("--rule-name", "test"), - ("--exclude-rule-name", "test"), - ("--rule-id", "test"), - ("--exclude-rule-id", "test"), - ("--rule-type", "FedEndpointExfiltration"), - ("--exclude-rule-type", "FedEndpointExfiltration"), - ("--description", "test"), - ("--state", "OPEN"), - ], + "protocol", (ServerProtocol.TLS_TCP, ServerProtocol.TLS_TCP, ServerProtocol.UDP) ) -def test_send_to_with_advanced_query_and_incompatible_argument_errors( - arg, cli_state, runner -): - - result = runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--advanced-query", ADVANCED_QUERY_JSON, *arg], - obj=cli_state, - ) - assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output - - -def test_send_to_when_given_begin_and_end_dates_uses_expected_query( - cli_state, alert_extractor, runner -): - begin_date = get_test_date_str(days_ago=89) - end_date = get_test_date_str(days_ago=1) - - runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", begin_date, "--end", end_date], - obj=cli_state, - ) - filters = alert_extractor.extract.call_args[0][0] - actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{}T00:00:00.000Z".format(begin_date) - actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{}T23:59:59.999Z".format(end_date) - assert actual_begin == expected_begin - assert actual_end == expected_end - - -def test_send_to_when_given_begin_and_end_date_and_times_uses_expected_query( - cli_state, alert_extractor, runner -): - begin_date = get_test_date_str(days_ago=89) - end_date = get_test_date_str(days_ago=1) - time = "15:33:02" - runner.invoke( - cli, - [ - "alerts", - "search", - "--begin", - "{} {}".format(begin_date, time), - "--end", - "{} {}".format(end_date, time), - ], - obj=cli_state, - ) - filters = alert_extractor.extract.call_args[0][0] - actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{}T{}.000Z".format(begin_date, time) - actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{}T{}.000Z".format(end_date, time) - assert actual_begin == expected_begin - assert actual_end == expected_end - - -def test_send_to_when_given_begin_date_and_time_without_seconds_uses_expected_query( - cli_state, alert_extractor, runner -): - date = get_test_date_str(days_ago=89) - time = "15:33" - runner.invoke( +def test_send_to_allows_protocol_arg(cli_state, runner, protocol): + res = runner.invoke( cli, - ["alerts", "send-to", "0.0.0.0", "--begin", "{} {}".format(date, time)], + ["alerts", "send-to", "0.0.0.0", "--begin", "1d", "--protocol", protocol], obj=cli_state, ) - actual = get_filter_value_from_json( - alert_extractor.extract.call_args[0][0], filter_index=0 - ) - expected = "{}T{}:00.000Z".format(date, time) - assert actual == expected + assert res.exit_code == 0 -def test_send_to_when_given_end_date_and_time_uses_expected_query( - cli_state, alert_extractor, runner -): - begin_date = get_test_date_str(days_ago=10) - end_date = get_test_date_str(days_ago=1) - time = "15:33" - runner.invoke( +def test_send_to_when_given_unknown_protocol_fails(cli_state, runner): + res = runner.invoke( cli, - [ - "alerts", - "search", - "--begin", - begin_date, - "--end", - "{} {}".format(end_date, time), - ], + ["alerts", "send-to", "0.0.0.0", "--begin", "1d", "--protocol", "ATM"], obj=cli_state, ) - actual = get_filter_value_from_json( - alert_extractor.extract.call_args[0][0], filter_index=1 - ) - expected = "{}T{}:00.000Z".format(end_date, time) - assert actual == expected + assert res.exit_code -def test_send_to_when_given_begin_date_more_than_ninety_days_back_errors( +def test_send_to_certs_and_ignore_cert_validation_args_are_incompatible( cli_state, runner ): - begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - result = runner.invoke( - cli, ["alerts", "send-to", "0.0.0.0", "--begin", begin_date], obj=cli_state - ) - assert "must be within 90 days" in result.output - - -def test_send_to_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - cli_state, alert_cursor_with_checkpoint, alert_extractor, runner -): - begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - runner.invoke( + res = runner.invoke( cli, [ "alerts", "send-to", "0.0.0.0", "--begin", - begin_date, - "--use-checkpoint", - "test", + "1d", + "--protocol", + "TLS-TCP", + "--certs", + "certs/file", + "--ignore-cert-validation", ], obj=cli_state, ) - assert not filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) - - -def test_send_to_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( - cli_state, alert_extractor, runner -): - begin_date = get_test_date_str(days_ago=1) - runner.invoke( - cli, ["alerts", "send-to", "0.0.0.0", "--begin", begin_date], obj=cli_state - ) - actual_ts = get_filter_value_from_json( - alert_extractor.extract.call_args[0][0], filter_index=0 - ) - expected_ts = "{}T00:00:00.000Z".format(begin_date) - assert actual_ts == expected_ts - assert filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) - - -def test_send_to_when_end_date_is_before_begin_date_causes_exit(cli_state, runner): - begin_date = get_test_date_str(days_ago=1) - end_date = get_test_date_str(days_ago=3) - result = runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", begin_date, "--end", end_date], - obj=cli_state, - ) - assert result.exit_code == 2 - assert "'--begin': cannot be after --end date" in result.output - - -def test_send_to_with_only_begin_calls_extract_with_expected_filters( - cli_state, alert_extractor, begin_option, runner -): - result = runner.invoke( - cli, ["alerts", "send-to", "0.0.0.0", "--begin", "1d"], obj=cli_state, - ) - assert result.exit_code == 0 - assert str( - alert_extractor.extract.call_args[0][0] - ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"createdAt", "value":"{}"}}]}}'.format( - begin_option.expected_timestamp - ) - - -def test_send_to_with_use_checkpoint_and_without_begin_and_without_stored_checkpoint_causes_expected_error( - cli_state, alert_cursor_without_checkpoint, runner -): - result = runner.invoke( - cli, ["alerts", "send-to", "0.0.0.0", "--use-checkpoint", "test"], obj=cli_state - ) - assert result.exit_code == 2 - assert ( - "--begin date is required for --use-checkpoint when no checkpoint exists yet." - in result.output - ) - - -def test_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( - cli_state, alert_extractor, begin_option, alert_cursor_without_checkpoint, runner, -): - result = runner.invoke( - cli, - ["alerts", "search", "--use-checkpoint", "test", "--begin", "1d"], - obj=cli_state, - ) - assert result.exit_code == 0 - assert len(alert_extractor.extract.call_args[0]) == 1 - assert begin_option.expected_timestamp in str( - alert_extractor.extract.call_args[0][0] - ) - - -def test_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( - cli_state, alert_extractor, alert_cursor_with_checkpoint, runner -): - - result = runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--use-checkpoint", "test", "--begin", "1h"], - obj=cli_state, - ) - assert result.exit_code == 0 - alert_extractor.extract.assert_called_with() - assert ( - "checkpoint of {} exists".format( - alert_cursor_with_checkpoint.expected_timestamp - ) - in result.output - ) - - -def test_send_to_when_given_actor_is_uses_username_filter( - cli_state, alert_extractor, runner -): - actor_name = "test.testerson" - - runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--actor", actor_name], - obj=cli_state, - ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.Actor.is_in([actor_name])) in filter_strings - + assert "Error: --ignore-cert-validation can't be used with: --certs" in res.output -def test_send_to_when_given_exclude_actor_uses_actor_filter( - cli_state, alert_extractor, runner -): - actor_name = "test.testerson" +def test_send_to_creates_expected_logger(cli_state, runner, send_to_logger_factory): runner.invoke( cli, [ @@ -1003,35 +754,22 @@ def test_send_to_when_given_exclude_actor_uses_actor_filter( "send-to", "0.0.0.0", "--begin", - "1h", - "--exclude-actor", - actor_name, + "1d", + "--protocol", + "TLS-TCP", + "--certs", + "certs/file", ], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.Actor.not_in([actor_name])) in filter_strings - - -def test_send_to_when_given_rule_name_uses_rule_name_filter( - cli_state, alert_extractor, runner -): - rule_name = "departing employee" - - runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--rule-name", rule_name], - obj=cli_state, + send_to_logger_factory.assert_called_once_with( + "0.0.0.0", "TLS-TCP", "RAW-JSON", "certs/file" ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleName.is_in([rule_name])) in filter_strings -def test_send_to_when_given_exclude_rule_name_uses_rule_name_not_filter( - cli_state, alert_extractor, runner +def test_send_to_when_given_ignore_cert_validation_uses_certs_equal_to_ignore_str( + cli_state, runner, send_to_logger_factory ): - rule_name = "departing employee" - runner.invoke( cli, [ @@ -1039,75 +777,27 @@ def test_send_to_when_given_exclude_rule_name_uses_rule_name_not_filter( "send-to", "0.0.0.0", "--begin", - "1h", - "--exclude-rule-name", - rule_name, + "1d", + "--protocol", + "TLS-TCP", + "--ignore-cert-validation", ], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleName.not_in([rule_name])) in filter_strings - - -def test_send_to_when_given_rule_type_uses_rule_name_filter( - cli_state, alert_extractor, runner -): - rule_type = "FedEndpointExfiltration" - - runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--rule-type", rule_type], - obj=cli_state, - ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleType.is_in([rule_type])) in filter_strings - - -def test_send_to_when_given_exclude_rule_type_uses_rule_name_not_filter( - cli_state, alert_extractor, runner -): - rule_type = "FedEndpointExfiltration" - - runner.invoke( - cli, - [ - "alerts", - "send-to", - "0.0.0.0", - "--begin", - "1h", - "--exclude-rule-type", - rule_type, - ], - obj=cli_state, + send_to_logger_factory.assert_called_once_with( + "0.0.0.0", "TLS-TCP", "RAW-JSON", "ignore" ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleType.not_in([rule_type])) in filter_strings - - -def test_send_to_when_given_rule_id_uses_rule_name_filter( - cli_state, alert_extractor, runner -): - rule_id = "departing employee" - runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--rule-id", rule_id], - obj=cli_state, - ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleId.is_in([rule_id])) in filter_strings +def test_get_alert_details_batches_results_according_to_batch_size(sdk): + extraction._ALERT_DETAIL_BATCH_SIZE = 2 + sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT + extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) + assert sdk.alerts.get_details.call_count == 10 -def test_send_to_when_given_exclude_rule_id_uses_rule_name_not_filter( - cli_state, alert_extractor, runner -): - rule_id = "departing employee" - runner.invoke( - cli, - ["alerts", "send-to", "0.0.0.0", "--begin", "1h", "--exclude-rule-id", rule_id], - obj=cli_state, - ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleId.not_in([rule_id])) in filter_strings +def test_get_alert_details_sorts_results_by_date(sdk): + extraction._ALERT_DETAIL_BATCH_SIZE = 2 + sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT + results = extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) + assert results == SORTED_ALERT_DETAILS diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index 2c9ca9436..c29bfdc0e 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -6,6 +6,7 @@ import pytest from py42.response import Py42Response from requests import Response +from tests.cmds.conftest import get_mark_for_search_and_send_to from code42cli.click_ext.types import MagicDate from code42cli.cmds.auditlogs import _parse_audit_log_timestamp_string_to_timestamp @@ -13,6 +14,7 @@ from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import round_datetime_to_day_end from code42cli.date_helper import round_datetime_to_day_start +from code42cli.logger.handlers import ServerProtocol from code42cli.main import cli from code42cli.util import hash_event @@ -26,7 +28,7 @@ { "type$": "audit_log::logged_in/1", "actorId": "42", - "actorName": "42@code42.com", + "actorName": "42@example.com", "actorAgent": "py42 python code42cli", "actorIpAddress": "200.100.300.42", "timestamp": TEST_AUDIT_LOG_TIMESTAMP_1, @@ -34,7 +36,7 @@ { "type$": "audit_log::logged_in/1", "actorId": "43", - "actorName": "43@code42.com", + "actorName": "43@example.com", "actorAgent": "py42 python code42cli", "actorIpAddress": "200.100.300.42", "timestamp": TEST_AUDIT_LOG_TIMESTAMP_1, @@ -45,7 +47,7 @@ { "type$": "audit_log::logged_in/1", "actorId": "44", - "actorName": "44@code42.com", + "actorName": "44@example.com", "actorAgent": "py42 python code42cli", "actorIpAddress": "200.100.300.42", "timestamp": TEST_AUDIT_LOG_TIMESTAMP_2, @@ -53,7 +55,7 @@ { "type$": "audit_log::logged_in/1", "actorId": "45", - "actorName": "45@code42.com", + "actorName": "45@example.com", "actorAgent": "py42 python code42cli", "actorIpAddress": "200.100.300.42", "timestamp": TEST_AUDIT_LOG_TIMESTAMP_3, @@ -62,6 +64,7 @@ TEST_CHECKPOINT_EVENT_HASHLIST = [ hash_event(event) for event in TEST_EVENTS_WITH_SAME_TIMESTAMP ] +search_and_send_to_test = get_mark_for_search_and_send_to("audit-logs") @pytest.fixture @@ -94,11 +97,14 @@ def date_str(): @pytest.fixture -def send_to_logger(mocker): +def send_to_logger_factory(mocker): + return mocker.patch("code42cli.cmds.search._try_get_logger_for_server") + + +@pytest.fixture +def send_to_logger(mocker, send_to_logger_factory): mock_logger = mocker.MagicMock(spec=Logger) - mocker.patch( - "code42cli.cmds.auditlogs.get_logger_for_server", return_value=mock_logger - ) + send_to_logger_factory.return_value = mock_logger return mock_logger @@ -135,12 +141,16 @@ def response_gen(): return response_gen() -def test_search_audit_logs_json_format(runner, cli_state, date_str): - runner.invoke(cli, ["audit-logs", "search", "-b", date_str], obj=cli_state) +@search_and_send_to_test +def test_search_and_send_to_handles_json_format(runner, cli_state, date_str, command): + runner.invoke(cli, [*command, "-b", date_str], obj=cli_state) assert cli_state.sdk.auditlogs.get_all.call_count == 1 -def test_search_audit_logs_with_filter_parameters(runner, cli_state, date_str): +@search_and_send_to_test +def test_search_and_send_to_handles_filter_parameters( + runner, cli_state, date_str, command +): expected_begin_timestamp = convert_datetime_to_timestamp( MagicDate(rounding_func=round_datetime_to_day_start).convert( date_str, None, None @@ -149,20 +159,18 @@ def test_search_audit_logs_with_filter_parameters(runner, cli_state, date_str): runner.invoke( cli, [ - "audit-logs", - "search", + *command, "--actor-username", - "test@test.com", + "test@example.com", "--actor-username", - "test2@test.test", + "test2@test.example.com", "--begin", date_str, ], obj=cli_state, ) - assert cli_state.sdk.auditlogs.get_all.call_count == 1 cli_state.sdk.auditlogs.get_all.assert_called_once_with( - usernames=("test@test.com", "test2@test.test"), + usernames=("test@example.com", "test2@test.example.com"), affected_user_ids=(), affected_usernames=(), begin_time=expected_begin_timestamp, @@ -173,7 +181,10 @@ def test_search_audit_logs_with_filter_parameters(runner, cli_state, date_str): ) -def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_str): +@search_and_send_to_test +def test_search_and_send_to_handles_all_filter_parameters( + runner, cli_state, date_str, command +): end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") expected_begin_timestamp = convert_datetime_to_timestamp( MagicDate(rounding_func=round_datetime_to_day_start).convert( @@ -186,18 +197,17 @@ def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_st runner.invoke( cli, [ - "audit-logs", - "search", + *command, "--actor-username", - "test@test.com", + "test@example.com", "--actor-username", - "test2@test.test", + "test2@test.example.com", "--event-type", "saved-search", "--actor-ip", "0.0.0.0", "--affected-username", - "test@test.test", + "test@test.example.com", "--affected-user-id", "123", "--affected-user-id", @@ -211,11 +221,10 @@ def test_search_audit_logs_with_all_filter_parameters(runner, cli_state, date_st ], obj=cli_state, ) - assert cli_state.sdk.auditlogs.get_all.call_count == 1 cli_state.sdk.auditlogs.get_all.assert_called_once_with( - usernames=("test@test.com", "test2@test.test"), + usernames=("test@example.com", "test2@test.example.com"), affected_user_ids=("123", "456"), - affected_usernames=("test@test.test",), + affected_usernames=("test@test.example.com",), begin_time=expected_begin_timestamp, end_time=expected_end_timestamp, event_types=("saved-search",), @@ -234,6 +243,49 @@ def test_send_to_makes_expected_call_count_to_the_logger_method( assert send_to_logger.info.call_count == 4 +def test_send_to_creates_expected_logger(cli_state, runner, send_to_logger_factory): + runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + "TLS-TCP", + "--certs", + "certs/file", + ], + obj=cli_state, + ) + send_to_logger_factory.assert_called_once_with( + "0.0.0.0", "TLS-TCP", "RAW-JSON", "certs/file" + ) + + +def test_send_to_when_given_ignore_cert_validation_uses_certs_equal_to_ignore_str( + cli_state, runner, send_to_logger_factory +): + runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + "TLS-TCP", + "--ignore-cert-validation", + ], + obj=cli_state, + ) + send_to_logger_factory.assert_called_once_with( + "0.0.0.0", "TLS-TCP", "RAW-JSON", "ignore" + ) + + def test_send_to_emits_events_in_chronological_order( cli_state, runner, send_to_logger, test_audit_log_response ): @@ -259,42 +311,18 @@ def test_send_to_emits_events_in_chronological_order( ) -def test_search_with_checkpoint_saves_expected_cursor_timestamp( - cli_state, runner, test_audit_log_response, audit_log_cursor_with_checkpoint -): - cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response - runner.invoke( - cli, - ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], - obj=cli_state, - ) - assert audit_log_cursor_with_checkpoint.replace.call_count == 4 - assert audit_log_cursor_with_checkpoint.replace.call_args_list[3][0] == ( - "test", - CURSOR_TIMESTAMP, - ) - - -def test_send_to_with_checkpoint_saves_expected_cursor_timestamp( +@search_and_send_to_test +def test_search_and_send_to_with_checkpoint_saves_expected_cursor_timestamp( cli_state, runner, + send_to_logger, test_audit_log_response, audit_log_cursor_with_checkpoint, - send_to_logger, + command, ): cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response runner.invoke( - cli, - [ - "audit-logs", - "send-to", - "localhost", - "--begin", - "1d", - "--use-checkpoint", - "test", - ], - obj=cli_state, + cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, ) assert audit_log_cursor_with_checkpoint.replace.call_count == 4 assert audit_log_cursor_with_checkpoint.replace.call_args_list[3][0] == ( @@ -303,34 +331,17 @@ def test_send_to_with_checkpoint_saves_expected_cursor_timestamp( ) -def test_search_with_existing_checkpoint_replaces_begin_arg_if_passed( - cli_state, runner, test_audit_log_response, audit_log_cursor_with_checkpoint -): - runner.invoke( - cli, - ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], - obj=cli_state, - ) - assert ( - cli_state.sdk.auditlogs.get_all.call_args[1]["begin_time"] == CURSOR_TIMESTAMP - ) - - -def test_send_to_with_existing_checkpoint_replaces_begin_arg_if_passed( - cli_state, runner, test_audit_log_response, audit_log_cursor_with_checkpoint +@search_and_send_to_test +def test_search_and_send_to_with_existing_checkpoint_replaces_begin_arg_if_passed( + cli_state, + runner, + send_to_logger, + test_audit_log_response, + audit_log_cursor_with_checkpoint, + command, ): runner.invoke( - cli, - [ - "audit-logs", - "send-to", - "localhost", - "--begin", - "1d", - "--use-checkpoint", - "test", - ], - obj=cli_state, + cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, ) assert ( cli_state.sdk.auditlogs.get_all.call_args[1]["begin_time"] == CURSOR_TIMESTAMP @@ -349,48 +360,24 @@ def test_search_with_existing_checkpoint_events_skips_duplicate_events( ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, ) - assert "42@code42.com" not in result.stdout - assert "43@code42.com" in result.stdout + assert "42@example.com" not in result.stdout + assert "43@example.com" in result.stdout -def test_send_to_with_existing_checkpoint_events_skips_duplicate_events( +@search_and_send_to_test +def test_search_and_send_to_without_existing_checkpoint_writes_both_event_hashes_with_same_timestamp( cli_state, runner, - test_audit_log_response, - audit_log_cursor_with_checkpoint_and_events, send_to_logger, -): - cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response - runner.invoke( - cli, - [ - "audit-logs", - "send-to", - "localhost", - "--begin", - "1d", - "--use-checkpoint", - "test", - ], - obj=cli_state, - ) - assert send_to_logger.info.call_count == 3 - assert send_to_logger.info.call_args_list[0][0][0]["actorName"] != "42@code42.com" - - -def test_search_without_existing_checkpoint_writes_both_event_hashes_with_same_timestamp( - cli_state, - runner, test_audit_log_response_with_only_same_timestamps, audit_log_cursor_with_checkpoint, + command, ): cli_state.sdk.auditlogs.get_all.return_value = ( test_audit_log_response_with_only_same_timestamps ) runner.invoke( - cli, - ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], - obj=cli_state, + cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, ) assert audit_log_cursor_with_checkpoint.replace_events.call_count == 2 assert audit_log_cursor_with_checkpoint.replace_events.call_args_list[1][0][1] == [ @@ -399,34 +386,47 @@ def test_search_without_existing_checkpoint_writes_both_event_hashes_with_same_t ] -def test_send_to_without_existing_checkpoint_writes_both_event_hashes_with_same_timestamp( - cli_state, - runner, - test_audit_log_response_with_only_same_timestamps, - audit_log_cursor_with_checkpoint, - send_to_logger, -): - cli_state.sdk.auditlogs.get_all.return_value = ( - test_audit_log_response_with_only_same_timestamps +@pytest.mark.parametrize( + "protocol", (ServerProtocol.TLS_TCP, ServerProtocol.TLS_TCP, ServerProtocol.UDP) +) +def test_send_to_allows_protocol_arg(cli_state, runner, protocol): + res = runner.invoke( + cli, + ["audit-logs", "send-to", "0.0.0.0", "--begin", "1d", "--protocol", protocol], + obj=cli_state, ) - runner.invoke( + assert res.exit_code == 0 + + +def test_send_when_given_unknown_protocol_fails(cli_state, runner): + res = runner.invoke( + cli, + ["audit-logs", "send-to", "0.0.0.0", "--begin", "1d", "--protocol", "ATM"], + obj=cli_state, + ) + assert res.exit_code + + +def test_send_to_certs_and_ignore_cert_validation_args_are_incompatible( + cli_state, runner +): + res = runner.invoke( cli, [ "audit-logs", "send-to", - "localhost", + "0.0.0.0", "--begin", "1d", - "--use-checkpoint", - "test", + "--protocol", + "TLS-TCP", + "--certs", + "certs/file", + "--ignore-cert-validation", ], obj=cli_state, ) - assert audit_log_cursor_with_checkpoint.replace_events.call_count == 2 - assert audit_log_cursor_with_checkpoint.replace_events.call_args_list[1][0][1] == [ - hash_event(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]), - hash_event(TEST_EVENTS_WITH_SAME_TIMESTAMP[1]), - ] + assert "Error: --ignore-cert-validation can't be used with: --certs" in res.output def test_audit_log_parse_timestamp_handles_possible_strings(): diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 6281db4c3..1568893f3 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -198,8 +198,8 @@ "userId": 1014, "userUid": "836473273124890369", "status": "Active", - "username": "qatest@code42.com", - "email": "qatest@code42.com", + "username": "test@example.com", + "email": "test@example.com", "firstName": "Chad", "lastName": "Valentine", "quotaInBytes": -1, diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index 6b4804370..da218be74 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -392,7 +392,7 @@ def test_remove_user_removes_user_if_user_in_matter( ], obj=cli_state, ) - cli_state.sdk.legalhold.remove_from_matter.assert_called_with(membership_uid) + cli_state.sdk.legalhold.remove_from_matter.assert_called_once_with(membership_uid) def test_matter_accessible_check_only_makes_one_http_call_when_called_multiple_times_with_same_matter_id( diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 6445eb061..d600b1af2 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -7,18 +7,18 @@ from py42.sdk.queries.fileevents.file_event_query import FileEventQuery from tests.cmds.conftest import filter_term_is_in_call_args from tests.cmds.conftest import get_filter_value_from_json +from tests.cmds.conftest import get_mark_for_search_and_send_to from tests.conftest import get_test_date_str from code42cli import errors from code42cli import PRODUCT_NAME from code42cli.cmds.search.cursor_store import FileEventCursorStore +from code42cli.logger.enums import ServerProtocol from code42cli.main import cli BEGIN_TIMESTAMP = 1577858400.0 END_TIMESTAMP = 1580450400.0 CURSOR_TIMESTAMP = 1579500000.0 - - TEST_LIST_RESPONSE = { "searches": [ { @@ -28,9 +28,7 @@ }, ] } - TEST_EMPTY_LIST_RESPONSE = {"searches": []} - ADVANCED_QUERY_VALUES = { "within_last_value": "P30D", "hostname_1": "DESKTOP-H88BEKO", @@ -92,6 +90,46 @@ }}""".format( **ADVANCED_QUERY_VALUES ) +advanced_query_incompat_test_params = pytest.mark.parametrize( + "arg", + [ + ("--begin", "1d"), + ("--end", "1d"), + ("--c42-username", "test@example.com"), + ("--actor", "test.testerson"), + ("--md5", "abcd1234"), + ("--sha256", "abcdefg12345678"), + ("--source", "Gmail"), + ("--file-name", "test.txt"), + ("--file-path", "C:\\Program Files"), + ("--file-category", "IMAGE"), + ("--process-owner", "root"), + ("--tab-url", "https://example.com"), + ("--type", "SharedViaLink"), + ("--include-non-exposure",), + ], +) +saved_search_incompat_test_params = pytest.mark.parametrize( + "arg", + [ + ("--begin", "1d"), + ("--end", "1d"), + ("--c42-username", "test@example.com"), + ("--actor", "test.testerson"), + ("--md5", "abcd1234"), + ("--sha256", "abcdefg12345678"), + ("--source", "Gmail"), + ("--file-name", "test.txt"), + ("--file-path", "C:\\Program Files"), + ("--file-category", "IMAGE"), + ("--process-owner", "root"), + ("--tab-url", "https://example.com"), + ("--type", "SharedViaLink"), + ("--include-non-exposure",), + ("--use-checkpoint", "test"), + ], +) +search_and_send_to_test = get_mark_for_search_and_send_to("security-data") @pytest.fixture @@ -136,23 +174,18 @@ def begin_option(mocker): return mock -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--advanced-query", ADVANCED_QUERY_JSON], - [ - "security-data", - "send-to", - "0.0.0.0", - "--advanced-query", - ADVANCED_QUERY_JSON, - ], - ), -) -def test_search_when_advanced_query_passed_as_json_string_builds_expected_query( +@pytest.fixture +def send_to_logger_factory(mocker): + return mocker.patch("code42cli.cmds.search._try_get_logger_for_server") + + +@search_and_send_to_test +def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_expected_query( runner, cli_state, file_event_extractor, command ): - runner.invoke(cli, command, obj=cli_state) + runner.invoke( + cli, [*command, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + ) passed_filter_groups = file_event_extractor.extract.call_args[0] expected_event_filter = f.EventTimestamp.within_the_last( ADVANCED_QUERY_VALUES["within_last_value"] @@ -169,21 +202,15 @@ def test_search_when_advanced_query_passed_as_json_string_builds_expected_query( assert expected_event_type_filter in passed_filter_groups -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--advanced-query", "@query.json"], - ["security-data", "send-to", "0.0.0.0", "--advanced-query", "@query.json"], - ), -) -def test_search_when_advanced_query_passed_as_filename_builds_expected_query( +@search_and_send_to_test +def test_search_and_send_to_when_advanced_query_passed_as_filename_builds_expected_query( runner, cli_state, file_event_extractor, command ): with runner.isolated_filesystem(): with open("query.json", "w") as jsonfile: jsonfile.write(ADVANCED_QUERY_JSON) - runner.invoke(cli, command, obj=cli_state) + runner.invoke(cli, [*command, "--advanced-query", "@query.json"], obj=cli_state) passed_filter_groups = file_event_extractor.extract.call_args[0] expected_event_filter = f.EventTimestamp.within_the_last( ADVANCED_QUERY_VALUES["within_last_value"] @@ -200,41 +227,19 @@ def test_search_when_advanced_query_passed_as_filename_builds_expected_query( assert expected_event_type_filter in passed_filter_groups -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--advanced-query", "@not_a_file"], - ["security-data", "send-to", "0.0.0.0", "--advanced-query", "@not_a_file"], - ), -) -def test_search_when_advanced_query_passed_non_existent_filename_raises_error( +@search_and_send_to_test +def test_search_and_send_to_when_advanced_query_passed_non_existent_filename_raises_error( runner, cli_state, command ): with runner.isolated_filesystem(): - result = runner.invoke(cli, command, obj=cli_state) + result = runner.invoke( + cli, [*command, "--advanced-query", "@not_a_file"], obj=cli_state + ) assert result.exit_code == 2 assert "Could not open file: not_a_file" in result.stdout -@pytest.mark.parametrize( - "arg", - [ - ("--begin", "1d"), - ("--end", "1d"), - ("--c42-username", "test@code42.com"), - ("--actor", "test.testerson"), - ("--md5", "abcd1234"), - ("--sha256", "abcdefg12345678"), - ("--source", "Gmail"), - ("--file-name", "test.txt"), - ("--file-path", "C:\\Program Files"), - ("--file-category", "IMAGE"), - ("--process-owner", "root"), - ("--tab-url", "https://example.com"), - ("--type", "SharedViaLink"), - ("--include-non-exposure",), - ], -) +@advanced_query_incompat_test_params def test_search_with_advanced_query_and_incompatible_argument_errors( runner, arg, cli_state ): @@ -247,26 +252,27 @@ def test_search_with_advanced_query_and_incompatible_argument_errors( assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output -@pytest.mark.parametrize( - "arg", - [ - ("--begin", "1d"), - ("--end", "1d"), - ("--c42-username", "test@code42.com"), - ("--actor", "test.testerson"), - ("--md5", "abcd1234"), - ("--sha256", "abcdefg12345678"), - ("--source", "Gmail"), - ("--file-name", "test.txt"), - ("--file-path", "C:\\Program Files"), - ("--file-category", "IMAGE"), - ("--process-owner", "root"), - ("--tab-url", "https://example.com"), - ("--type", "SharedViaLink"), - ("--include-non-exposure",), - ("--use-checkpoint", "test"), - ], -) +@advanced_query_incompat_test_params +def test_send_to_with_advanced_query_and_incompatible_argument_errors( + runner, arg, cli_state +): + result = runner.invoke( + cli, + [ + "security-data", + "send-to", + "0.0.0.0", + "--advanced-query", + ADVANCED_QUERY_JSON, + *arg, + ], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + + +@saved_search_incompat_test_params def test_search_with_saved_search_and_incompatible_argument_errors( runner, arg, cli_state ): @@ -279,35 +285,35 @@ def test_search_with_saved_search_and_incompatible_argument_errors( assert "{} can't be used with: --saved-search".format(arg[0]) in result.output -@pytest.mark.parametrize( - "command", - ( - [ - "security-data", - "search", - "--begin", - get_test_date_str(days_ago=89), - "--end", - get_test_date_str(days_ago=1), - ], +@saved_search_incompat_test_params +def test_send_to_with_saved_search_and_incompatible_argument_errors( + runner, arg, cli_state +): + result = runner.invoke( + cli, + ["security-data", "send-to", "0.0.0.0", "--saved-search", "test_id", *arg], + obj=cli_state, + ) + assert result.exit_code == 2 + assert "{} can't be used with: --saved-search".format(arg[0]) in result.output + + +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( + runner, cli_state, file_event_extractor, command +): + begin_date = get_test_date_str(days_ago=89) + end_date = get_test_date_str(days_ago=1) + runner.invoke( + cli, [ - "security-data", - "send-to", - "0.0.0.0", + *command, "--begin", get_test_date_str(days_ago=89), "--end", get_test_date_str(days_ago=1), ], - ), -) -def test_command_when_given_begin_and_end_dates_uses_expected_query( - runner, cli_state, file_event_extractor, command -): - begin_date = get_test_date_str(days_ago=89) - end_date = get_test_date_str(days_ago=1) - runner.invoke( - cli, command, obj=cli_state, + obj=cli_state, ) filters = file_event_extractor.extract.call_args[0][1] actual_begin = get_filter_value_from_json(filters, filter_index=0) @@ -318,8 +324,9 @@ def test_command_when_given_begin_and_end_dates_uses_expected_query( assert actual_end == expected_end -def test_search_when_given_begin_and_end_date_and_time_uses_expected_query( - runner, cli_state, file_event_extractor +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_and_end_date_and_time_uses_expected_query( + runner, cli_state, file_event_extractor, command ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) @@ -327,8 +334,7 @@ def test_search_when_given_begin_and_end_date_and_time_uses_expected_query( runner.invoke( cli, [ - "security-data", - "search", + *command, "--begin", "{} {}".format(begin_date, time), "--end", @@ -345,15 +351,14 @@ def test_search_when_given_begin_and_end_date_and_time_uses_expected_query( assert actual_end == expected_end -def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_query( - runner, cli_state, file_event_extractor +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_date_and_time_without_seconds_uses_expected_query( + runner, cli_state, file_event_extractor, command ): date = get_test_date_str(days_ago=89) time = "15:33" runner.invoke( - cli, - ["security-data", "search", "--begin", "{} {}".format(date, time)], - obj=cli_state, + cli, [*command, "--begin", "{} {}".format(date, time)], obj=cli_state, ) actual = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 @@ -362,22 +367,16 @@ def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_que assert actual == expected -def test_search_when_given_end_date_and_time_uses_expected_query( - runner, cli_state, file_event_extractor +@search_and_send_to_test +def test_search_and_send_to_when_given_end_date_and_time_uses_expected_query( + runner, cli_state, file_event_extractor, command ): begin_date = get_test_date_str(days_ago=10) end_date = get_test_date_str(days_ago=1) time = "15:33" runner.invoke( cli, - [ - "security-data", - "search", - "--begin", - begin_date, - "--end", - "{} {}".format(end_date, time), - ], + [*command, "--begin", begin_date, "--end", "{} {}".format(end_date, time)], obj=cli_state, ) actual = get_filter_value_from_json( @@ -387,39 +386,27 @@ def test_search_when_given_end_date_and_time_uses_expected_query( assert actual == expected -@pytest.mark.parametrize( - "command", - ( - [ - "security-data", - "search", - "--begin", - get_test_date_str(days_ago=91) + " 12:51:00", - ], - [ - "security-data", - "send-to", - "0.0.0.0", - "--begin", - get_test_date_str(days_ago=91) + " 12:51:00", - ], - ), -) -def test_command_when_given_begin_date_more_than_ninety_days_back_errors( +@search_and_send_to_test +def test_search_send_to_when_given_begin_date_more_than_ninety_days_back_errors( runner, cli_state, command ): - result = runner.invoke(cli, command, obj=cli_state) + result = runner.invoke( + cli, + [*command, "--begin", get_test_date_str(days_ago=91) + " 12:51:00"], + obj=cli_state, + ) assert result.exit_code == 2 assert "must be within 90 days" in result.output -def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - runner, cli_state, file_event_cursor_with_checkpoint, file_event_extractor +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( + runner, cli_state, file_event_cursor_with_checkpoint, file_event_extractor, command ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" runner.invoke( cli, - ["security-data", "search", "--begin", begin_date, "--use-checkpoint", "test"], + [*command, "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state, ) assert not filter_term_is_in_call_args( @@ -427,13 +414,12 @@ def test_search_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stor ) -def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( - runner, cli_state, file_event_extractor +@search_and_send_to_test +def test_search_and_send_to_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( + runner, cli_state, file_event_extractor, command ): begin_date = get_test_date_str(days_ago=1) - runner.invoke( - cli, ["security-data", "search", "--begin", begin_date], obj=cli_state - ) + runner.invoke(cli, [*command, "--begin", begin_date], obj=cli_state) actual_ts = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 ) @@ -442,24 +428,24 @@ def test_search_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_u assert filter_term_is_in_call_args(file_event_extractor, f.EventTimestamp._term) -def test_search_when_end_date_is_before_begin_date_causes_exit(runner, cli_state): +@search_and_send_to_test +def test_search_and_send_to_when_end_date_is_before_begin_date_causes_exit( + runner, cli_state, command +): begin_date = get_test_date_str(days_ago=1) end_date = get_test_date_str(days_ago=3) result = runner.invoke( - cli, - ["security-data", "search", "--begin", begin_date, "--end", end_date], - obj=cli_state, + cli, [*command, "--begin", begin_date, "--end", end_date], obj=cli_state, ) assert result.exit_code == 2 assert "'--begin': cannot be after --end date" in result.output -def test_search_with_only_begin_calls_extract_with_expected_args( - runner, cli_state, file_event_extractor, begin_option +@search_and_send_to_test +def test_search_and_send_to_with_only_begin_calls_extract_with_expected_args( + runner, cli_state, file_event_extractor, begin_option, command ): - result = runner.invoke( - cli, ["security-data", "search", "--begin", "1h"], obj=cli_state - ) + result = runner.invoke(cli, [*command, "--begin", "1h"], obj=cli_state) assert result.exit_code == 0 assert str( file_event_extractor.extract.call_args[0][1] @@ -468,12 +454,11 @@ def test_search_with_only_begin_calls_extract_with_expected_args( ) -def test_search_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_expected_error( - runner, cli_state, file_event_cursor_without_checkpoint +@search_and_send_to_test +def test_search_and_send_to_with_use_checkpoint_and_without_begin_and_without_checkpoint_causes_expected_error( + runner, cli_state, file_event_cursor_without_checkpoint, command ): - result = runner.invoke( - cli, ["security-data", "search", "--use-checkpoint", "test"], obj=cli_state - ) + result = runner.invoke(cli, [*command, "--use-checkpoint", "test"], obj=cli_state) assert result.exit_code == 2 assert ( "--begin date is required for --use-checkpoint when no checkpoint exists yet." @@ -481,22 +466,8 @@ def test_search_with_use_checkpoint_and_without_begin_and_without_checkpoint_cau ) -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], - [ - "security-data", - "send-to", - "0.0.0.0", - "--use-checkpoint", - "test", - "--begin", - "1h", - ], - ), -) -def test_command_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( +@search_and_send_to_test +def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( runner, cli_state, file_event_extractor, @@ -504,7 +475,9 @@ def test_command_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls file_event_cursor_without_checkpoint, command, ): - result = runner.invoke(cli, command, obj=cli_state,) + result = runner.invoke( + cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, + ) assert result.exit_code == 0 assert len(file_event_extractor.extract.call_args[0]) == 2 assert begin_option.expected_timestamp in str( @@ -512,25 +485,13 @@ def test_command_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls ) -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--use-checkpoint", "test", "--begin", "1h"], - [ - "security-data", - "send-to", - "0.0.0.0", - "--use-checkpoint", - "test", - "--begin", - "1h", - ], - ), -) -def test_command_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( +@search_and_send_to_test +def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( runner, cli_state, file_event_extractor, file_event_cursor_with_checkpoint, command, ): - result = runner.invoke(cli, command, obj=cli_state,) + result = runner.invoke( + cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, + ) assert result.exit_code == 0 assert len(file_event_extractor.extract.call_args[0]) == 1 assert ( @@ -541,88 +502,60 @@ def test_command_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_c ) -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1d", "-t", "NotValid"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1d", "-t", "NotValid"], - ), -) -def test_command_when_given_invalid_exposure_type_causes_exit( +@search_and_send_to_test +def test_search_and_send_to_when_given_invalid_exposure_type_causes_exit( runner, cli_state, command ): - result = runner.invoke(cli, command, obj=cli_state,) + result = runner.invoke( + cli, [*command, "--begin", "1d", "-t", "NotValid"], obj=cli_state, + ) assert result.exit_code == 2 assert "invalid choice: NotValid" in result.output -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--c42-username"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--c42-username"], - ), -) -def test_command_when_given_username_uses_username_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_username_uses_username_filter( runner, cli_state, file_event_extractor, command ): - c42_username = "test@code42.com" - command.append(c42_username) + c42_username = "test@example.com" + command = [*command, "--begin", "1h", "--c42-username", c42_username] runner.invoke( - cli, command, obj=cli_state, + cli, [*command], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.DeviceUsername.is_in([c42_username])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--actor"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--actor"], - ), -) -def test_command_when_given_actor_is_uses_username_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_actor_is_uses_username_filter( runner, cli_state, file_event_extractor, command ): actor_name = "test.testerson" - command.append(actor_name) + command = [*command, "--begin", "1h", "--actor", actor_name] runner.invoke( - cli, command, obj=cli_state, + cli, [*command], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.Actor.is_in([actor_name])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--md5"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--md5"], - ), -) -def test_command_when_given_md5_uses_md5_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_md5_uses_md5_filter( runner, cli_state, file_event_extractor, command ): md5 = "abcd12345" - command.append(md5) - runner.invoke(cli, command, obj=cli_state) + command = [*command, "--begin", "1h", "--md5", md5] + runner.invoke(cli, [*command], obj=cli_state) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.MD5.is_in([md5])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--sha256"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--sha256"], - ), -) -def test_command_when_given_sha256_uses_sha256_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_sha256_uses_sha256_filter( runner, cli_state, file_event_extractor, command ): sha_256 = "abcd12345" - command.append(sha_256) + command = [*command, "--begin", "1h", "--sha256", sha_256] runner.invoke( cli, command, obj=cli_state, ) @@ -630,35 +563,23 @@ def test_command_when_given_sha256_uses_sha256_filter( assert str(f.SHA256.is_in([sha_256])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--source"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--source"], - ), -) -def test_command_when_given_source_uses_source_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_source_uses_source_filter( runner, cli_state, file_event_extractor, command ): source = "Gmail" - command.append(source) + command = [*command, "--begin", "1h", "--source", source] runner.invoke(cli, command, obj=cli_state) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.Source.is_in([source])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--file-name"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--file-name"], - ), -) -def test_command_when_given_file_name_uses_file_name_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_file_name_uses_file_name_filter( runner, cli_state, file_event_extractor, command ): filename = "test.txt" - command.append(filename) + command = [*command, "--begin", "1h", "--file-name", filename] runner.invoke( cli, command, obj=cli_state, ) @@ -666,18 +587,12 @@ def test_command_when_given_file_name_uses_file_name_filter( assert str(f.FileName.is_in([filename])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--file-path"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--file-path"], - ), -) -def test_command_when_given_file_path_uses_file_path_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_file_path_uses_file_path_filter( runner, cli_state, file_event_extractor, command ): filepath = "C:\\Program Files" - command.append(filepath) + command = [*command, "--begin", "1h", "--file-path", filepath] runner.invoke( cli, command, obj=cli_state, ) @@ -685,31 +600,25 @@ def test_command_when_given_file_path_uses_file_path_filter( assert str(f.FilePath.is_in([filepath])) in filter_strings -def test_search_when_given_file_category_uses_file_category_filter( - runner, cli_state, file_event_extractor +@search_and_send_to_test +def test_search_and_send_to_when_given_file_category_uses_file_category_filter( + runner, cli_state, file_event_extractor, command ): file_category = "IMAGE" + command = [*command, "--begin", "1h", "--file-category", file_category] runner.invoke( - cli, - ["security-data", "search", "--begin", "1h", "--file-category", file_category], - obj=cli_state, + cli, command, obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.FileCategory.is_in([file_category])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--process-owner"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--process-owner"], - ), -) -def test_when_given_process_owner_uses_process_owner_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_process_owner_uses_process_owner_filter( runner, cli_state, file_event_extractor, command ): process_owner = "root" - command.append(process_owner) + command = [*command, "-b", "1h", "--process-owner", process_owner] runner.invoke( cli, command, obj=cli_state, ) @@ -717,18 +626,12 @@ def test_when_given_process_owner_uses_process_owner_filter( assert str(f.ProcessOwner.is_in([process_owner])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--tab-url"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--tab-url"], - ), -) -def test_when_given_tab_url_uses_process_tab_url_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_tab_url_uses_process_tab_url_filter( runner, cli_state, file_event_extractor, command ): tab_url = "https://example.com" - command.append(tab_url) + command = [*command, "--begin", "1h", "--tab-url", tab_url] runner.invoke( cli, command, obj=cli_state, ) @@ -736,18 +639,12 @@ def test_when_given_tab_url_uses_process_tab_url_filter( assert str(f.TabURL.is_in([tab_url])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--type"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h", "--type"], - ), -) -def test_when_given_exposure_types_uses_exposure_type_is_in_filter( +@search_and_send_to_test +def test_search_and_send_to_when_given_exposure_types_uses_exposure_type_is_in_filter( runner, cli_state, file_event_extractor, command ): exposure_type = "SharedViaLink" - command.append(exposure_type) + command = [*command, "--begin", "1h", "--type", exposure_type] runner.invoke( cli, command, obj=cli_state, ) @@ -755,58 +652,39 @@ def test_when_given_exposure_types_uses_exposure_type_is_in_filter( assert str(f.ExposureType.is_in([exposure_type])) in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h", "--include-non-exposure"], - [ - "security-data", - "send-to", - "0.0.0.0", - "--begin", - "1h", - "--include-non-exposure", - ], - ), -) -def test_when_given_include_non_exposure_does_not_include_exposure_type_exists( +@search_and_send_to_test +def test_search_and_send_to_when_given_include_non_exposure_does_not_include_exposure_type_exists( runner, cli_state, file_event_extractor, command ): runner.invoke( - cli, command, obj=cli_state, + cli, [*command, "--begin", "1h", "--include-non-exposure"], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.ExposureType.exists()) not in filter_strings -@pytest.mark.parametrize( - "command", - ( - ["security-data", "search", "--begin", "1h"], - ["security-data", "send-to", "0.0.0.0", "--begin", "1h"], - ), -) -def test_when_not_given_include_non_exposure_includes_exposure_type_exists( +@search_and_send_to_test +def test_search_and_send_to_when_not_given_include_non_exposure_includes_exposure_type_exists( runner, cli_state, file_event_extractor, command ): runner.invoke( - cli, command, obj=cli_state, + cli, [*command, "--begin", "1h"], obj=cli_state, ) filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] assert str(f.ExposureType.exists()) in filter_strings -def test_when_given_multiple_search_args_uses_expected_filters( - runner, cli_state, file_event_extractor +@search_and_send_to_test +def test_search_and_send_to_when_given_multiple_search_args_uses_expected_filters( + runner, cli_state, file_event_extractor, command ): process_owner = "root" - c42_username = "test@code42.com" + c42_username = "test@example.com" filename = "test.txt" runner.invoke( cli, [ - "security-data", - "search", + *command, "--begin", "1h", "--process-owner", @@ -824,14 +702,14 @@ def test_when_given_multiple_search_args_uses_expected_filters( assert str(f.DeviceUsername.is_in([c42_username])) in filter_strings -def test_when_given_include_non_exposure_and_exposure_types_causes_exit( - runner, cli_state, file_event_extractor +@search_and_send_to_test +def test_search_and_send_to_when_given_include_non_exposure_and_exposure_types_causes_exit( + runner, cli_state, file_event_extractor, command ): result = runner.invoke( cli, [ - "security-data", - "search", + *command, "--begin", "1h", "--include-non-exposure", @@ -843,8 +721,9 @@ def test_when_given_include_non_exposure_and_exposure_types_causes_exit( assert result.exit_code == 2 -def test_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( - runner, cli_state, caplog +@search_and_send_to_test +def test_search_and_send_to_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( + runner, cli_state, caplog, command ): errors.ERRORED = False exception_msg = "Test Exception" @@ -854,14 +733,70 @@ def file_search_error(x): cli_state.sdk.securitydata.search_file_events.side_effect = file_search_error with caplog.at_level(logging.ERROR): - result = runner.invoke( - cli, ["security-data", "search", "--begin", "1d"], obj=cli_state - ) + result = runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) assert exception_msg in result.output assert exception_msg in caplog.text assert errors.ERRORED +@search_and_send_to_test +def test_search_and_send_to_with_or_query_flag_produces_expected_query( + runner, cli_state, command +): + begin_date = get_test_date_str(days_ago=10) + test_username = "test@example.com" + test_filename = "test.txt" + runner.invoke( + cli, + [ + *command, + "--or-query", + "--begin", + begin_date, + "--c42-username", + test_username, + "--file-name", + test_filename, + ], + obj=cli_state, + ) + expected_query = { + "groupClause": "AND", + "groups": [ + { + "filterClause": "AND", + "filters": [ + {"operator": "EXISTS", "term": "exposure", "value": None}, + { + "operator": "ON_OR_AFTER", + "term": "eventTimestamp", + "value": "{}T00:00:00.000Z".format(begin_date), + }, + ], + }, + { + "filterClause": "OR", + "filters": [ + { + "operator": "IS", + "term": "deviceUserName", + "value": "test@example.com", + }, + {"operator": "IS", "term": "fileName", "value": "test.txt"}, + ], + }, + ], + "pgNum": 1, + "pgSize": 10000, + "srtDir": "asc", + "srtKey": "insertionTimestamp", + } + actual_query = json.loads( + str(cli_state.sdk.securitydata.search_file_events.call_args[0][0]) + ) + assert actual_query == expected_query + + def test_saved_search_calls_extractor_extract_and_saved_search_execute( runner, cli_state, file_event_extractor ): @@ -904,73 +839,111 @@ def test_saved_search_calls_extractor_extract_and_saved_search_execute( assert str(file_event_extractor.extract.call_args[0][1]) in str(query) -def test_saved_search_list_calls_get_method(runner, cli_state): - runner.invoke(cli, ["security-data", "saved-search", "list"], obj=cli_state) - assert cli_state.sdk.securitydata.savedsearches.get.call_count == 1 +@pytest.mark.parametrize( + "protocol", (ServerProtocol.TLS_TCP, ServerProtocol.TLS_TCP, ServerProtocol.UDP) +) +def test_send_to_allows_protocol_arg(cli_state, runner, protocol): + res = runner.invoke( + cli, + [ + "security-data", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + protocol, + ], + obj=cli_state, + ) + assert res.exit_code == 0 -def test_show_detail_calls_get_by_id_method(runner, cli_state): - test_id = "test_id" - runner.invoke( - cli, ["security-data", "saved-search", "show", test_id], obj=cli_state +def test_send_to_fails_when_given_unknown_protocol(cli_state, runner): + res = runner.invoke( + cli, + ["security-data", "send-to", "0.0.0.0", "--begin", "1d", "--protocol", "ATM"], + obj=cli_state, ) - cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with(test_id) + assert res.exit_code -def test_search_with_or_query_flag_produces_expected_query(runner, cli_state): - begin_date = get_test_date_str(days_ago=10) - test_username = "test@example.com" - test_filename = "test.txt" +def test_send_to_certs_and_ignore_cert_validation_args_are_incompatible( + cli_state, runner +): + res = runner.invoke( + cli, + [ + "security-data", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + "TLS-TCP", + "--certs", + "certs/file", + "--ignore-cert-validation", + ], + obj=cli_state, + ) + assert "Error: --ignore-cert-validation can't be used with: --certs" in res.output + + +def test_send_to_creates_expected_logger(cli_state, runner, send_to_logger_factory): runner.invoke( cli, [ "security-data", - "search", - "--or-query", + "send-to", + "0.0.0.0", "--begin", - begin_date, - "--c42-username", - test_username, - "--file-name", - test_filename, + "1d", + "--protocol", + "TLS-TCP", + "--certs", + "certs/file", ], obj=cli_state, ) - expected_query = { - "groupClause": "AND", - "groups": [ - { - "filterClause": "AND", - "filters": [ - {"operator": "EXISTS", "term": "exposure", "value": None}, - { - "operator": "ON_OR_AFTER", - "term": "eventTimestamp", - "value": "{}T00:00:00.000Z".format(begin_date), - }, - ], - }, - { - "filterClause": "OR", - "filters": [ - { - "operator": "IS", - "term": "deviceUserName", - "value": "test@example.com", - }, - {"operator": "IS", "term": "fileName", "value": "test.txt"}, - ], - }, + send_to_logger_factory.assert_called_once_with( + "0.0.0.0", "TLS-TCP", "RAW-JSON", "certs/file" + ) + + +def test_send_to_when_given_ignore_cert_validation_uses_certs_equal_to_ignore_str( + cli_state, runner, send_to_logger_factory +): + runner.invoke( + cli, + [ + "security-data", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + "TLS-TCP", + "--ignore-cert-validation", ], - "pgNum": 1, - "pgSize": 10000, - "srtDir": "asc", - "srtKey": "insertionTimestamp", - } - actual_query = json.loads( - str(cli_state.sdk.securitydata.search_file_events.call_args[0][0]) + obj=cli_state, ) - assert actual_query == expected_query + send_to_logger_factory.assert_called_once_with( + "0.0.0.0", "TLS-TCP", "RAW-JSON", "ignore" + ) + + +def test_saved_search_list_calls_get_method(runner, cli_state): + runner.invoke(cli, ["security-data", "saved-search", "list"], obj=cli_state) + assert cli_state.sdk.securitydata.savedsearches.get.call_count == 1 + + +def test_saved_search_show_detail_calls_get_by_id_method(runner, cli_state): + test_id = "test_id" + runner.invoke( + cli, ["security-data", "saved-search", "show", test_id], obj=cli_state + ) + cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with(test_id) def test_saved_search_list_with_format_option_returns_csv_formatted_response( diff --git a/tests/cmds/test_securitydata_output_formats.py b/tests/cmds/test_securitydata_output_formats.py deleted file mode 100644 index 29f6c954a..000000000 --- a/tests/cmds/test_securitydata_output_formats.py +++ /dev/null @@ -1,573 +0,0 @@ -import json - -import pytest -from c42eventextractor.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP - -from code42cli.cmds.search.enums import FileEventsOutputFormat -from code42cli.cmds.securitydata_output_formats import FileEventsOutputFormatter -from code42cli.cmds.securitydata_output_formats import to_cef - - -AED_CLOUD_ACTIVITY_EVENT_DICT = json.loads( - """{ - "url": "https://www.example.com", - "syncDestination": "TEST_SYNC_DESTINATION", - "sharedWith": [{"cloudUsername": "example1@example.com"}, {"cloudUsername": "example2@example.com"}], - "cloudDriveId": "TEST_CLOUD_DRIVE_ID", - "actor": "actor@example.com", - "tabUrl": "TEST_TAB_URL", - "windowTitle": "TEST_WINDOW_TITLE" - }""" -) - - -AED_REMOVABLE_MEDIA_EVENT_DICT = json.loads( - """{ - "removableMediaVendor": "TEST_VENDOR_NAME", - "removableMediaName": "TEST_NAME", - "removableMediaSerialNumber": "TEST_SERIAL_NUMBER", - "removableMediaCapacity": 5000000, - "removableMediaBusType": "TEST_BUS_TYPE" - }""" -) - - -AED_EMAIL_EVENT_DICT = json.loads( - """{ - "emailSender": "TEST_EMAIL_SENDER", - "emailRecipients": ["test.recipient1@example.com", "test.recipient2@example.com"] - }""" -) - - -AED_EVENT_DICT = json.loads( - """{ - "eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16", - "eventType": "READ_BY_APP", - "eventTimestamp": "2019-09-09T02:42:23.851Z", - "insertionTimestamp": "2019-09-09T22:47:42.724Z", - "filePath": "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/", - "fileName": "InfoPlist.strings", - "fileType": "FILE", - "fileCategory": "UNCATEGORIZED", - "fileSize": 86, - "fileOwner": "testtesterson", - "md5Checksum": "19b92e63beb08c27ab4489fcfefbbe44", - "sha256Checksum": "2e0677355c37fa18fd20d372c7420b8b34de150c5801910c3bbb1e8e04c727ef", - "createTimestamp": "2012-07-22T02:19:29Z", - "modifyTimestamp": "2012-12-19T03:00:08Z", - "deviceUserName": "test.testerson+testair@code42.com", - "osHostName": "Test's MacBook Air", - "domainName": "192.168.0.3", - "publicIpAddress": "71.34.4.22", - "privateIpAddresses": [ - "fe80:0:0:0:f053:a9bd:973:6c8c%utun1", - "fe80:0:0:0:a254:cb31:8840:9d6b%utun0", - "0:0:0:0:0:0:0:1%lo0", - "192.168.0.3", - "fe80:0:0:0:0:0:0:1%lo0", - "fe80:0:0:0:8c28:1ac9:5745:a6e7%utun3", - "fe80:0:0:0:2e4a:351c:bb9b:2f28%utun2", - "fe80:0:0:0:6df:855c:9436:37f8%utun4", - "fe80:0:0:0:ce:5072:e5f:7155%en0", - "fe80:0:0:0:b867:afff:fefc:1a82%awdl0", - "127.0.0.1" - ], - "deviceUid": "912339407325443353", - "userUid": "912338501981077099", - "actor": null, - "directoryId": [], - "source": "Endpoint", - "url": null, - "shared": null, - "sharedWith": [], - "sharingTypeAdded": [], - "cloudDriveId": null, - "detectionSourceAlias": null, - "fileId": null, - "exposure": [ - "ApplicationRead" - ], - "processOwner": "testtesterson", - "processName": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "removableMediaVendor": null, - "removableMediaName": null, - "removableMediaSerialNumber": null, - "removableMediaCapacity": null, - "removableMediaBusType": null, - "syncDestination": null - }""" -) - - -@pytest.fixture -def mock_file_event_removable_media_event(): - return AED_REMOVABLE_MEDIA_EVENT_DICT - - -@pytest.fixture -def mock_file_event_cloud_activity_event(): - return AED_CLOUD_ACTIVITY_EVENT_DICT - - -@pytest.fixture -def mock_file_event_email_event(): - return AED_EMAIL_EVENT_DICT - - -@pytest.fixture -def mock_file_event(): - return AED_EVENT_DICT - - -@pytest.fixture -def mock_to_cef(mocker): - return mocker.patch("code42cli.cmds.securitydata_output_formats.to_cef") - - -class TestFileEventsOutputFormatter: - def test_init_sets_format_func_to_dynamic_csv_function_when_csv_option_is_passed( - self, mock_to_csv - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CSV) - for _ in formatter.get_formatted_output("TEST"): - pass - mock_to_csv.assert_called_once_with("TEST") - - def test_init_sets_format_func_to_formatted_json_function_when_json__option_is_passed( - self, mock_to_formatted_json - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.JSON) - for _ in formatter.get_formatted_output(["TEST"]): - pass - mock_to_formatted_json.assert_called_once_with("TEST") - - def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_passed( - self, mock_to_json - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.RAW) - for _ in formatter.get_formatted_output(["TEST"]): - pass - mock_to_json.assert_called_once_with("TEST") - - def test_init_sets_format_func_to_cef_function_when_cef_format_option_is_passed( - self, mock_to_cef - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CEF) - for _ in formatter.get_formatted_output(["TEST"]): - pass - mock_to_cef.assert_called_once_with("TEST") - - def test_init_sets_format_func_to_table_function_when_table_format_option_is_passed( - self, mock_to_table - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.TABLE) - for _ in formatter.get_formatted_output("TEST"): - pass - mock_to_table.assert_called_once_with("TEST", None) - - def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed( - self, mock_to_table - ): - formatter = FileEventsOutputFormatter(None) - for _ in formatter.get_formatted_output("TEST"): - pass - mock_to_table.assert_called_once_with("TEST", None) - - -def test_to_cef_returns_cef_tagged_string(mock_file_event): - cef_out = to_cef(mock_file_event) - cef_parts = get_cef_parts(cef_out) - assert cef_parts[0] == "CEF:0" - - -def test_to_cef_uses_correct_vendor_name(mock_file_event): - cef_out = to_cef(mock_file_event) - cef_parts = get_cef_parts(cef_out) - assert cef_parts[1] == "Code42" - - -def test_to_cef_uses_correct_default_product_name(mock_file_event): - cef_out = to_cef(mock_file_event) - cef_parts = get_cef_parts(cef_out) - assert cef_parts[2] == "Advanced Exfiltration Detection" - - -def test_to_cef_uses_correct_default_severity(mock_file_event): - cef_out = to_cef(mock_file_event) - cef_parts = get_cef_parts(cef_out) - assert cef_parts[6] == "5" - - -def test_to_cef_excludes_none_values_from_output(mock_file_event): - cef_out = to_cef(mock_file_event) - cef_parts = get_cef_parts(cef_out) - assert "=None " not in cef_parts[-1] - - -def test_to_cef_excludes_empty_values_from_output(mock_file_event): - cef_out = to_cef(mock_file_event) - cef_parts = get_cef_parts(cef_out) - assert "= " not in cef_parts[-1] - - -def test_to_cef_excludes_file_event_fields_not_in_cef_map(mock_file_event): - test_value = "definitelyExcludedValue" - mock_file_event["unmappedFieldName"] = test_value - cef_out = to_cef(mock_file_event) - cef_parts = get_cef_parts(cef_out) - del mock_file_event["unmappedFieldName"] - assert test_value not in cef_parts[-1] - - -def test_to_cef_includes_os_hostname_if_present(mock_file_event): - expected_field_name = "shost" - expected_value = "Test's MacBook Air" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_public_ip_address_if_present(mock_file_event): - expected_field_name = "src" - expected_value = "71.34.4.22" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_user_uid_if_present(mock_file_event): - expected_field_name = "suid" - expected_value = "912338501981077099" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_device_username_if_present(mock_file_event): - expected_field_name = "suser" - expected_value = "test.testerson+testair@code42.com" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_capacity_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cn1" - expected_value = "5000000" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_capacity_label_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cn1Label" - expected_value = "Code42AEDRemovableMediaCapacity" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_bus_type_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cs1" - expected_value = "TEST_BUS_TYPE" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_bus_type_label_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cs1Label" - expected_value = "Code42AEDRemovableMediaBusType" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_vendor_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cs2" - expected_value = "TEST_VENDOR_NAME" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_vendor_label_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cs2Label" - expected_value = "Code42AEDRemovableMediaVendor" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_name_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cs3" - expected_value = "TEST_NAME" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_name_label_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cs3Label" - expected_value = "Code42AEDRemovableMediaName" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_serial_number_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cs4" - expected_value = "TEST_SERIAL_NUMBER" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_removable_media_serial_number_label_if_present( - mock_file_event_removable_media_event, -): - expected_field_name = "cs4Label" - expected_value = "Code42AEDRemovableMediaSerialNumber" - cef_out = to_cef(mock_file_event_removable_media_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_actor_if_present(mock_file_event_cloud_activity_event,): - expected_field_name = "suser" - expected_value = "actor@example.com" - cef_out = to_cef(mock_file_event_cloud_activity_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_sync_destination_if_present( - mock_file_event_cloud_activity_event, -): - expected_field_name = "destinationServiceName" - expected_value = "TEST_SYNC_DESTINATION" - cef_out = to_cef(mock_file_event_cloud_activity_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_event_timestamp_if_present(mock_file_event): - expected_field_name = "end" - expected_value = "1567996943851" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_create_timestamp_if_present(mock_file_event): - expected_field_name = "fileCreateTime" - expected_value = "1342923569000" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_md5_checksum_if_present(mock_file_event): - expected_field_name = "fileHash" - expected_value = "19b92e63beb08c27ab4489fcfefbbe44" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_modify_timestamp_if_present(mock_file_event): - expected_field_name = "fileModificationTime" - expected_value = "1355886008000" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_file_path_if_present(mock_file_event): - expected_field_name = "filePath" - expected_value = "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_file_name_if_present(mock_file_event): - expected_field_name = "fname" - expected_value = "InfoPlist.strings" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_file_size_if_present(mock_file_event): - expected_field_name = "fsize" - expected_value = "86" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_file_category_if_present(mock_file_event): - expected_field_name = "fileType" - expected_value = "UNCATEGORIZED" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_exposure_if_present(mock_file_event): - expected_field_name = "reason" - expected_value = "ApplicationRead" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_url_if_present(mock_file_event_cloud_activity_event,): - expected_field_name = "filePath" - expected_value = "https://www.example.com" - cef_out = to_cef(mock_file_event_cloud_activity_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_insertion_timestamp_if_present(mock_file_event): - expected_field_name = "rt" - expected_value = "1568069262724" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_process_name_if_present(mock_file_event): - expected_field_name = "sproc" - expected_value = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_event_id_if_present(mock_file_event): - expected_field_name = "externalId" - expected_value = "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_device_uid_if_present(mock_file_event): - expected_field_name = "deviceExternalId" - expected_value = "912339407325443353" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_domain_name_if_present(mock_file_event): - expected_field_name = "dvchost" - expected_value = "192.168.0.3" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_source_if_present(mock_file_event): - expected_field_name = "sourceServiceName" - expected_value = "Endpoint" - cef_out = to_cef(mock_file_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_cloud_drive_id_if_present( - mock_file_event_cloud_activity_event, -): - expected_field_name = "aid" - expected_value = "TEST_CLOUD_DRIVE_ID" - cef_out = to_cef(mock_file_event_cloud_activity_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_shared_with_if_present(mock_file_event_cloud_activity_event,): - expected_field_name = "duser" - expected_value = "example1@example.com,example2@example.com" - cef_out = to_cef(mock_file_event_cloud_activity_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_tab_url_if_present(mock_file_event_cloud_activity_event,): - expected_field_name = "request" - expected_value = "TEST_TAB_URL" - cef_out = to_cef(mock_file_event_cloud_activity_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_window_title_if_present(mock_file_event_cloud_activity_event,): - expected_field_name = "requestClientApplication" - expected_value = "TEST_WINDOW_TITLE" - cef_out = to_cef(mock_file_event_cloud_activity_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_email_recipients_if_present(mock_file_event_email_event,): - expected_field_name = "duser" - expected_value = "test.recipient1@example.com,test.recipient2@example.com" - cef_out = to_cef(mock_file_event_email_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_email_sender_if_present(mock_file_event_email_event,): - expected_field_name = "suser" - expected_value = "TEST_EMAIL_SENDER" - cef_out = to_cef(mock_file_event_email_event) - assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) - - -def test_to_cef_includes_correct_event_name_and_signature_id_for_created( - mock_file_event, -): - event_type = "CREATED" - mock_file_event["eventType"] = event_type - cef_out = to_cef(mock_file_event) - assert event_name_assigned_correct_signature_id(event_type, "C42200", cef_out) - - -def test_to_cef_includes_correct_event_name_and_signature_id_for_modified( - mock_file_event, -): - event_type = "MODIFIED" - mock_file_event["eventType"] = event_type - cef_out = to_cef(mock_file_event) - assert event_name_assigned_correct_signature_id(event_type, "C42201", cef_out) - - -def test_to_cef_includes_correct_event_name_and_signature_id_for_deleted( - mock_file_event, -): - event_type = "DELETED" - mock_file_event["eventType"] = event_type - cef_out = to_cef(mock_file_event) - assert event_name_assigned_correct_signature_id(event_type, "C42202", cef_out) - - -def test_to_cef_includes_correct_event_name_and_signature_id_for_read_by_app( - mock_file_event, -): - event_type = "READ_BY_APP" - mock_file_event["eventType"] = event_type - cef_out = to_cef(mock_file_event) - assert event_name_assigned_correct_signature_id(event_type, "C42203", cef_out) - - -def test_to_cef_includes_correct_event_name_and_signature_id_for_emailed( - mock_file_event_email_event, -): - event_type = "EMAILED" - mock_file_event_email_event["eventType"] = event_type - cef_out = to_cef(mock_file_event_email_event) - assert event_name_assigned_correct_signature_id(event_type, "C42204", cef_out) - - -def get_cef_parts(cef_str): - return cef_str.split("|") - - -def key_value_pair_in_cef_extension(field_name, field_value, cef_str): - cef_parts = get_cef_parts(cef_str) - kvp = "{}={}".format(field_name, field_value) - return kvp in cef_parts[-1] - - -def event_name_assigned_correct_signature_id(event_name, signature_id, cef_out): - if event_name in FILE_EVENT_TO_SIGNATURE_ID_MAP: - cef_parts = get_cef_parts(cef_out) - return cef_parts[4] == signature_id and cef_parts[5] == event_name - - return False diff --git a/tests/logger/__init__.py b/tests/logger/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/logger/conftest.py b/tests/logger/conftest.py new file mode 100644 index 000000000..c50a04db9 --- /dev/null +++ b/tests/logger/conftest.py @@ -0,0 +1,119 @@ +import json +import logging + +import pytest + + +AED_CLOUD_ACTIVITY_EVENT_DICT = json.loads( + """{ + "url": "https://www.example.com", + "syncDestination": "TEST_SYNC_DESTINATION", + "sharedWith": [{"cloudUsername": "example1@example.com"}, {"cloudUsername": "example2@example.com"}], + "cloudDriveId": "TEST_CLOUD_DRIVE_ID", + "actor": "actor@example.com", + "tabUrl": "TEST_TAB_URL", + "windowTitle": "TEST_WINDOW_TITLE" + }""" +) +AED_REMOVABLE_MEDIA_EVENT_DICT = json.loads( + """{ + "removableMediaVendor": "TEST_VENDOR_NAME", + "removableMediaName": "TEST_NAME", + "removableMediaSerialNumber": "TEST_SERIAL_NUMBER", + "removableMediaCapacity": 5000000, + "removableMediaBusType": "TEST_BUS_TYPE" + }""" +) +AED_EMAIL_EVENT_DICT = json.loads( + """{ + "emailSender": "TEST_EMAIL_SENDER", + "emailRecipients": ["test.recipient1@example.com", "test.recipient2@example.com"] + }""" +) +AED_EVENT_DICT = json.loads( + """{ + "eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16", + "eventType": "READ_BY_APP", + "eventTimestamp": "2019-09-09T02:42:23.851Z", + "insertionTimestamp": "2019-09-09T22:47:42.724Z", + "filePath": "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/", + "fileName": "InfoPlist.strings", + "fileType": "FILE", + "fileCategory": "UNCATEGORIZED", + "fileSize": 86, + "fileOwner": "testtesterson", + "md5Checksum": "19b92e63beb08c27ab4489fcfefbbe44", + "sha256Checksum": "2e0677355c37fa18fd20d372c7420b8b34de150c5801910c3bbb1e8e04c727ef", + "createTimestamp": "2012-07-22T02:19:29Z", + "modifyTimestamp": "2012-12-19T03:00:08Z", + "deviceUserName": "test.testerson+testair@example.com", + "osHostName": "Test's MacBook Air", + "domainName": "192.168.0.3", + "publicIpAddress": "71.34.4.22", + "privateIpAddresses": [ + "fe80:0:0:0:f053:a9bd:973:6c8c%utun1", + "fe80:0:0:0:a254:cb31:8840:9d6b%utun0", + "0:0:0:0:0:0:0:1%lo0", + "192.168.0.3", + "fe80:0:0:0:0:0:0:1%lo0", + "fe80:0:0:0:8c28:1ac9:5745:a6e7%utun3", + "fe80:0:0:0:2e4a:351c:bb9b:2f28%utun2", + "fe80:0:0:0:6df:855c:9436:37f8%utun4", + "fe80:0:0:0:ce:5072:e5f:7155%en0", + "fe80:0:0:0:b867:afff:fefc:1a82%awdl0", + "127.0.0.1" + ], + "deviceUid": "912339407325443353", + "userUid": "912338501981077099", + "actor": null, + "directoryId": [], + "source": "Endpoint", + "url": null, + "shared": null, + "sharedWith": [], + "sharingTypeAdded": [], + "cloudDriveId": null, + "detectionSourceAlias": null, + "fileId": null, + "exposure": [ + "ApplicationRead" + ], + "processOwner": "testtesterson", + "processName": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "removableMediaVendor": null, + "removableMediaName": null, + "removableMediaSerialNumber": null, + "removableMediaCapacity": null, + "removableMediaBusType": null, + "syncDestination": null + }""" +) + + +@pytest.fixture() +def mock_log_record(mocker): + return mocker.MagicMock(spec=logging.LogRecord) + + +@pytest.fixture +def mock_file_event_log_record(mock_log_record): + mock_log_record.msg = AED_EVENT_DICT + return mock_log_record + + +@pytest.fixture +def mock_file_event_removable_media_event_log_record(mock_log_record): + mock_log_record.msg = AED_REMOVABLE_MEDIA_EVENT_DICT + return mock_log_record + + +@pytest.fixture +def mock_file_event_cloud_activity_event_log_record(mock_log_record): + mock_log_record.msg = AED_CLOUD_ACTIVITY_EVENT_DICT + return mock_log_record + + +@pytest.fixture +def mock_file_event_email_event_log_record(mock_log_record): + mock_log_record.msg = AED_EMAIL_EVENT_DICT + return mock_log_record diff --git a/tests/logger/test_formatters.py b/tests/logger/test_formatters.py new file mode 100644 index 000000000..a3606892a --- /dev/null +++ b/tests/logger/test_formatters.py @@ -0,0 +1,546 @@ +import json + +from code42cli.logger.formatters import FileEventDictToCEFFormatter +from code42cli.logger.formatters import FileEventDictToJSONFormatter +from code42cli.logger.formatters import FileEventDictToRawJSONFormatter +from code42cli.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP + + +class TestFileEventDictToCEFFormatter: + def test_format_returns_cef_tagged_string(self, mock_file_event_log_record): + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[0] == "CEF:0" + + def test_format_uses_correct_vendor_name(self, mock_file_event_log_record): + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[1] == "Code42" + + def test_format_uses_correct_default_product_name(self, mock_file_event_log_record): + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[2] == "Advanced Exfiltration Detection" + + def test_format_uses_correct_product_name(self, mock_file_event_log_record): + alternate_product_name = "Security Parser Formatter Extractor Thingamabob" + cef_out = FileEventDictToCEFFormatter( + default_product_name=alternate_product_name + ).format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[2] == alternate_product_name + + def test_format_uses_correct_default_severity(self, mock_file_event_log_record): + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[6] == "5" + + def test_format_uses_correct_severity(self, mock_file_event_log_record): + alternate_severity = "7" + cef_out = FileEventDictToCEFFormatter( + default_severity_level=alternate_severity + ).format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[6] == alternate_severity + + def test_format_excludes_none_values_from_output(self, mock_file_event_log_record): + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + assert "=None " not in cef_parts[-1] + + def test_format_excludes_empty_values_from_output(self, mock_file_event_log_record): + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + assert "= " not in cef_parts[-1] + + def test_format_excludes_file_event_fields_not_in_cef_map( + self, mock_file_event_log_record + ): + test_value = "definitelyExcludedValue" + mock_file_event_log_record.msg["unmappedFieldName"] = test_value + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + cef_parts = get_cef_parts(cef_out) + del mock_file_event_log_record.msg["unmappedFieldName"] + assert test_value not in cef_parts[-1] + + def test_format_includes_os_hostname_if_present(self, mock_file_event_log_record): + expected_field_name = "shost" + expected_value = "Test's MacBook Air" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_public_ip_address_if_present( + self, mock_file_event_log_record + ): + expected_field_name = "src" + expected_value = "71.34.4.22" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_user_uid_if_present(self, mock_file_event_log_record): + expected_field_name = "suid" + expected_value = "912338501981077099" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_device_username_if_present( + self, mock_file_event_log_record + ): + expected_field_name = "suser" + expected_value = "test.testerson+testair@example.com" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_capacity_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cn1" + expected_value = "5000000" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_capacity_label_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cn1Label" + expected_value = "Code42AEDRemovableMediaCapacity" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_bus_type_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cs1" + expected_value = "TEST_BUS_TYPE" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_bus_type_label_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cs1Label" + expected_value = "Code42AEDRemovableMediaBusType" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_vendor_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cs2" + expected_value = "TEST_VENDOR_NAME" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_vendor_label_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cs2Label" + expected_value = "Code42AEDRemovableMediaVendor" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_name_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cs3" + expected_value = "TEST_NAME" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_name_label_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cs3Label" + expected_value = "Code42AEDRemovableMediaName" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_serial_number_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cs4" + expected_value = "TEST_SERIAL_NUMBER" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_removable_media_serial_number_label_if_present( + self, mock_file_event_removable_media_event_log_record + ): + expected_field_name = "cs4Label" + expected_value = "Code42AEDRemovableMediaSerialNumber" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_removable_media_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_actor_if_present( + self, mock_file_event_cloud_activity_event_log_record + ): + expected_field_name = "suser" + expected_value = "actor@example.com" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_cloud_activity_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_sync_destination_if_present( + self, mock_file_event_cloud_activity_event_log_record + ): + expected_field_name = "destinationServiceName" + expected_value = "TEST_SYNC_DESTINATION" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_cloud_activity_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_event_timestamp_if_present( + self, mock_file_event_log_record + ): + expected_field_name = "end" + expected_value = "1567996943851" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_create_timestamp_if_present( + self, mock_file_event_log_record + ): + expected_field_name = "fileCreateTime" + expected_value = "1342923569000" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_md5_checksum_if_present(self, mock_file_event_log_record): + expected_field_name = "fileHash" + expected_value = "19b92e63beb08c27ab4489fcfefbbe44" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_modify_timestamp_if_present( + self, mock_file_event_log_record + ): + expected_field_name = "fileModificationTime" + expected_value = "1355886008000" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_file_path_if_present(self, mock_file_event_log_record): + expected_field_name = "filePath" + expected_value = "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_file_name_if_present(self, mock_file_event_log_record): + expected_field_name = "fname" + expected_value = "InfoPlist.strings" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_file_size_if_present(self, mock_file_event_log_record): + expected_field_name = "fsize" + expected_value = "86" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_file_category_if_present(self, mock_file_event_log_record): + expected_field_name = "fileType" + expected_value = "UNCATEGORIZED" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_exposure_if_present(self, mock_file_event_log_record): + expected_field_name = "reason" + expected_value = "ApplicationRead" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_url_if_present( + self, mock_file_event_cloud_activity_event_log_record + ): + expected_field_name = "filePath" + expected_value = "https://www.example.com" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_cloud_activity_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_insertion_timestamp_if_present( + self, mock_file_event_log_record + ): + expected_field_name = "rt" + expected_value = "1568069262724" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_process_name_if_present(self, mock_file_event_log_record): + expected_field_name = "sproc" + expected_value = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_event_id_if_present(self, mock_file_event_log_record): + expected_field_name = "externalId" + expected_value = "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_device_uid_if_present(self, mock_file_event_log_record): + expected_field_name = "deviceExternalId" + expected_value = "912339407325443353" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_domain_name_if_present(self, mock_file_event_log_record): + expected_field_name = "dvchost" + expected_value = "192.168.0.3" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_source_if_present(self, mock_file_event_log_record): + expected_field_name = "sourceServiceName" + expected_value = "Endpoint" + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_cloud_drive_id_if_present( + self, mock_file_event_cloud_activity_event_log_record + ): + expected_field_name = "aid" + expected_value = "TEST_CLOUD_DRIVE_ID" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_cloud_activity_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_shared_with_if_present( + self, mock_file_event_cloud_activity_event_log_record + ): + expected_field_name = "duser" + expected_value = "example1@example.com,example2@example.com" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_cloud_activity_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_tab_url_if_present( + self, mock_file_event_cloud_activity_event_log_record + ): + expected_field_name = "request" + expected_value = "TEST_TAB_URL" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_cloud_activity_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_window_title_if_present( + self, mock_file_event_cloud_activity_event_log_record + ): + expected_field_name = "requestClientApplication" + expected_value = "TEST_WINDOW_TITLE" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_cloud_activity_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_email_recipients_if_present( + self, mock_file_event_email_event_log_record + ): + expected_field_name = "duser" + expected_value = "test.recipient1@example.com,test.recipient2@example.com" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_email_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_email_sender_if_present( + self, mock_file_event_email_event_log_record + ): + expected_field_name = "suser" + expected_value = "TEST_EMAIL_SENDER" + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_email_event_log_record + ) + assert key_value_pair_in_cef_extension( + expected_field_name, expected_value, cef_out + ) + + def test_format_includes_correct_event_name_and_signature_id_for_created( + self, mock_file_event_log_record + ): + event_type = "CREATED" + mock_file_event_log_record.msg["eventType"] = event_type + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert event_name_assigned_correct_signature_id(event_type, "C42200", cef_out) + + def test_format_includes_correct_event_name_and_signature_id_for_modified( + self, mock_file_event_log_record + ): + event_type = "MODIFIED" + mock_file_event_log_record.msg["eventType"] = event_type + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert event_name_assigned_correct_signature_id(event_type, "C42201", cef_out) + + def test_format_includes_correct_event_name_and_signature_id_for_deleted( + self, mock_file_event_log_record + ): + event_type = "DELETED" + mock_file_event_log_record.msg["eventType"] = event_type + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert event_name_assigned_correct_signature_id(event_type, "C42202", cef_out) + + def test_format_includes_correct_event_name_and_signature_id_for_read_by_app( + self, mock_file_event_log_record + ): + event_type = "READ_BY_APP" + mock_file_event_log_record.msg["eventType"] = event_type + cef_out = FileEventDictToCEFFormatter().format(mock_file_event_log_record) + assert event_name_assigned_correct_signature_id(event_type, "C42203", cef_out) + + def test_format_includes_correct_event_name_and_signature_id_for_emailed( + self, mock_file_event_email_event_log_record + ): + event_type = "EMAILED" + mock_file_event_email_event_log_record.msg["eventType"] = event_type + cef_out = FileEventDictToCEFFormatter().format( + mock_file_event_email_event_log_record + ) + assert event_name_assigned_correct_signature_id(event_type, "C42204", cef_out) + + +class TestFileEventDictToJSONFormatter: + def test_format_returns_expected_number_of_fields(self, mock_file_event_log_record): + json_out = FileEventDictToJSONFormatter().format(mock_file_event_log_record) + file_event_dict = json.loads(json_out) + assert len(file_event_dict) == 25 # Fields that are not null or an empty list + + def test_format_returns_only_non_null_fields(self, mock_file_event_log_record): + json_out = FileEventDictToJSONFormatter().format(mock_file_event_log_record) + file_event_dict = json.loads(json_out) + for key in file_event_dict: + if not file_event_dict[key] and file_event_dict != 0: + raise AssertionError() + assert True + + +class TestFileEventDictToRawJSONFormatter: + def test_format_returns_expected_number_of_fields(self, mock_file_event_log_record): + json_out = FileEventDictToRawJSONFormatter().format(mock_file_event_log_record) + file_event_dict = json.loads(json_out) + assert len(file_event_dict) == 40 + + def test_format_is_okay_with_null_values(self, mock_file_event_log_record): + json_out = FileEventDictToRawJSONFormatter().format(mock_file_event_log_record) + file_event_dict = json.loads(json_out) + assert ( + file_event_dict["actor"] is None + ) # actor happens to be null in this case. + + +def get_cef_parts(cef_str): + return cef_str.split("|") + + +def key_value_pair_in_cef_extension(field_name, field_value, cef_str): + cef_parts = get_cef_parts(cef_str) + kvp = "{}={}".format(field_name, field_value) + return kvp in cef_parts[-1] + + +def event_name_assigned_correct_signature_id(event_name, signature_id, cef_out): + if event_name in FILE_EVENT_TO_SIGNATURE_ID_MAP: + cef_parts = get_cef_parts(cef_out) + return cef_parts[4] == signature_id and cef_parts[5] == event_name + + # `assert False` can cause test call to be removed, according to flake8. + raise AssertionError() diff --git a/tests/logger/test_handlers.py b/tests/logger/test_handlers.py new file mode 100644 index 000000000..436287fa5 --- /dev/null +++ b/tests/logger/test_handlers.py @@ -0,0 +1,238 @@ +import ssl +from socket import IPPROTO_TCP +from socket import IPPROTO_UDP +from socket import SOCK_DGRAM +from socket import SOCK_STREAM +from socket import socket +from socket import SocketKind + +import pytest + +from code42cli.logger import FileEventDictToRawJSONFormatter +from code42cli.logger.enums import ServerProtocol +from code42cli.logger.handlers import NoPrioritySysLogHandler +from code42cli.logger.handlers import SyslogServerNetworkConnectionError + +_TEST_HOST = "example.com" +_TEST_PORT = 5000 +_TEST_CERTS = "path/to/cert.crt" +tls_and_tcp_test = pytest.mark.parametrize( + "protocol", (ServerProtocol.TLS_TCP, ServerProtocol.TCP) +) +tcp_and_udp_test = pytest.mark.parametrize( + "protocol", (ServerProtocol.TCP, ServerProtocol.UDP) +) + + +class SocketMocks: + mock_socket = None + socket_initializer = None + + class SSLMocks: + mock_ssl_context = None + context_creator = None + + +@pytest.fixture(autouse=True) +def socket_mocks(mocker): + mocks = SocketMocks() + new_socket = mocker.MagicMock(spec=ssl.SSLSocket) + mocks.mock_socket = new_socket + mocks.socket_initializer = _get_normal_socket_initializer_mocks(mocker, new_socket) + mocks.SSLMocks.mock_ssl_context = mocker.MagicMock(ssl.SSLContext) + mocks.SSLMocks.mock_ssl_context.wrap_socket.return_value = new_socket + mocks.SSLMocks.context_creator = mocker.patch( + "code42cli.logger.handlers.ssl.create_default_context" + ) + mocks.SSLMocks.context_creator.return_value = mocks.SSLMocks.mock_ssl_context + return mocks + + +@pytest.fixture() +def broken_pipe_error(mocker): + mock_exc_info = mocker.patch("code42cli.logger.handlers.sys.exc_info") + mock_exc_info.return_value = (BrokenPipeError, None, None) + return mock_exc_info + + +def _get_normal_socket_initializer_mocks(mocker, new_socket): + new_socket_magic_method = mocker.patch( + "code42cli.logger.handlers.socket.socket.__new__" + ) + new_socket_magic_method.return_value = new_socket + return new_socket_magic_method + + +class TestNoPrioritySysLogHandler: + def test_init_sets_expected_address(self): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + assert handler.address == (_TEST_HOST, _TEST_PORT) + + @tls_and_tcp_test + def test_init_when_stream_based_sets_expected_sock_type(self, protocol): + handler = NoPrioritySysLogHandler(_TEST_HOST, _TEST_PORT, protocol, None) + actual = handler.socktype + assert actual == SocketKind.SOCK_STREAM + + def test_init_when_udp_sets_expected_sock_type(self): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + actual = handler.socktype + assert actual == SocketKind.SOCK_DGRAM + + def test_init_sets_socket_to_none(self): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + assert handler.socket is None + + @tcp_and_udp_test + def test_init_when_not_tls_sets_wrap_socket_to_false(self, protocol): + handler = NoPrioritySysLogHandler(_TEST_HOST, _TEST_PORT, protocol, None) + assert not handler._wrap_socket + + def test_init_when_using_tls_sets_wrap_socket_to_true(self): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.TLS_TCP, _TEST_CERTS + ) + assert handler._wrap_socket + assert handler._certs == _TEST_CERTS + + def test_connect_socket_only_connects_once(self, socket_mocks): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + handler.connect_socket() + handler.connect_socket() + assert socket_mocks.socket_initializer.call_count == 1 + + def test_connect_socket_when_udp_initializes_with_expected_properties( + self, socket_mocks + ): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + handler.connect_socket() + call_args = socket_mocks.socket_initializer.call_args[0] + assert call_args[0] == socket + assert call_args[2] == SOCK_DGRAM + assert call_args[3] == IPPROTO_UDP + + @tls_and_tcp_test + def test_connect_socket_when_tcp_initializes_with_expected_properties( + self, socket_mocks, protocol + ): + handler = NoPrioritySysLogHandler(_TEST_HOST, _TEST_PORT, protocol, None) + handler.connect_socket() + call_args = socket_mocks.socket_initializer.call_args[0] + assert call_args[0] == socket + assert call_args[2] == SOCK_STREAM + assert call_args[3] == IPPROTO_TCP + assert socket_mocks.mock_socket.connect.call_count == 1 + + def test_connect_when_tls_calls_create_default_context(self, socket_mocks): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.TLS_TCP, "certs" + ) + handler.connect_socket() + call_args = socket_mocks.SSLMocks.context_creator.call_args + assert call_args[1]["cafile"] == "certs" + + @pytest.mark.parametrize("ignore", ("ignore", "IGNORE")) + def test_connect_when_tls_and_told_to_ignore_certs_sets_expected_context_properties( + self, socket_mocks, ignore + ): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.TLS_TCP, ignore + ) + handler.connect_socket() + assert socket_mocks.SSLMocks.mock_ssl_context.verify_mode == ssl.CERT_NONE + assert not socket_mocks.SSLMocks.mock_ssl_context.check_hostname + + @pytest.mark.parametrize("ignore", ("ignore", "IGNORE")) + def test_connect_when_tls_and_told_to_ignore_certs_creates_context_with_none_certs( + self, socket_mocks, ignore + ): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.TLS_TCP, ignore + ) + handler.connect_socket() + socket_mocks.SSLMocks.context_creator.assert_called_once_with(cafile=None) + + @tls_and_tcp_test + def test_connect_socket_when_tcp_or_tls_sets_timeout_for_connection_and_resets( + self, socket_mocks, protocol + ): + handler = NoPrioritySysLogHandler(_TEST_HOST, _TEST_PORT, protocol, None) + handler.connect_socket() + call_args = socket_mocks.mock_socket.settimeout.call_args_list + assert len(call_args) == 2 + assert call_args[0][0][0] == 10 + assert call_args[1][0][0] is None + + @tls_and_tcp_test + def test_emit_when_tcp_calls_socket_sendall_with_expected_message( + self, mock_file_event_log_record, protocol + ): + handler = NoPrioritySysLogHandler(_TEST_HOST, _TEST_PORT, protocol, None) + handler.connect_socket() + formatter = FileEventDictToRawJSONFormatter() + handler.setFormatter(formatter) + handler.emit(mock_file_event_log_record) + expected_message = (formatter.format(mock_file_event_log_record) + "\n").encode( + "utf-8" + ) + handler.socket.sendall.assert_called_once_with(expected_message) + + def test_emit_when_udp_calls_socket_sendto_with_expected_message_and_address( + self, mock_file_event_log_record + ): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + handler.connect_socket() + formatter = FileEventDictToRawJSONFormatter() + handler.setFormatter(formatter) + handler.emit(mock_file_event_log_record) + expected_message = (formatter.format(mock_file_event_log_record) + "\n").encode( + "utf-8" + ) + handler.socket.sendto.assert_called_once_with( + expected_message, (_TEST_HOST, _TEST_PORT) + ) + + def test_handle_error_raises_expected_error( + self, mock_file_event_log_record, broken_pipe_error + ): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + with pytest.raises(SyslogServerNetworkConnectionError): + handler.handleError(mock_file_event_log_record) + + def test_close_when_using_tls_unwraps_socket(self): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.TLS_TCP, None + ) + handler.connect_socket() + handler.close() + assert handler.socket.unwrap.call_count == 1 + + @tcp_and_udp_test + def test_close_when_not_using_tls_does_not_unwrap_socket(self, protocol): + handler = NoPrioritySysLogHandler(_TEST_HOST, _TEST_PORT, protocol, None) + handler.connect_socket() + handler.close() + assert not handler.socket.unwrap.call_count + + def test_close_globally_closes(self, mocker): + global_close = mocker.patch("code42cli.logger.handlers.logging.Handler.close") + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + handler.connect_socket() + handler.close() + assert global_close.call_count == 1 diff --git a/tests/logger/test_init.py b/tests/logger/test_init.py new file mode 100644 index 000000000..16c259b78 --- /dev/null +++ b/tests/logger/test_init.py @@ -0,0 +1,168 @@ +import logging +import os +from logging.handlers import RotatingFileHandler + +import pytest +from requests import Request + +from code42cli.logger import add_handler_to_logger +from code42cli.logger import CliLogger +from code42cli.logger import get_logger_for_server +from code42cli.logger import get_view_error_details_message +from code42cli.logger import logger_has_handlers +from code42cli.logger.enums import ServerProtocol +from code42cli.logger.formatters import FileEventDictToCEFFormatter +from code42cli.logger.formatters import FileEventDictToJSONFormatter +from code42cli.logger.formatters import FileEventDictToRawJSONFormatter +from code42cli.logger.handlers import NoPrioritySysLogHandler +from code42cli.output_formats import OutputFormat +from code42cli.output_formats import SendToFileEventsOutputFormat +from code42cli.util import get_user_project_path + + +@pytest.fixture(autouse=True) +def init_socket_mock(mocker): + return mocker.patch("code42cli.logger.NoPrioritySysLogHandler.connect_socket") + + +@pytest.fixture(autouse=True) +def fresh_syslog_handler(init_socket_mock): + # Set handlers to empty list so it gets initialized each test + get_logger_for_server( + "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None, + ).handlers = [] + init_socket_mock.call_count = 0 + + +def test_add_handler_to_logger_does_as_expected(): + logger = logging.getLogger("TEST_CODE42_CLI") + formatter = logging.Formatter() + handler = logging.Handler() + add_handler_to_logger(logger, handler, formatter) + assert handler in logger.handlers + assert handler.formatter == formatter + + +def test_logger_has_handlers_when_logger_has_handlers_returns_true(): + logger = logging.getLogger("TEST_CODE42_CLI") + handler = logging.Handler() + logger.addHandler(handler) + assert logger_has_handlers(logger) + + +def test_logger_has_handlers_when_logger_does_not_have_handlers_returns_false(): + logger = logging.getLogger("TEST_CODE42_CLI") + logger.handlers = [] + assert not logger_has_handlers(logger) + + +def test_get_view_exceptions_location_message_returns_expected_message(): + actual = get_view_error_details_message() + path = os.path.join(get_user_project_path("log"), "code42_errors.log") + expected = "View details in {}".format(path) + assert actual == expected + + +def test_get_logger_for_server_has_info_level(): + logger = get_logger_for_server( + "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None + ) + assert logger.level == logging.INFO + + +def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(): + logger = get_logger_for_server( + "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None + ) + assert type(logger.handlers[0].formatter) == FileEventDictToCEFFormatter + + +def test_get_logger_for_server_when_given_json_format_uses_json_formatter(): + logger = get_logger_for_server( + "example.com", ServerProtocol.TCP, OutputFormat.JSON, None + ) + actual = type(logger.handlers[0].formatter) + assert actual == FileEventDictToJSONFormatter + + +def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter(): + logger = get_logger_for_server( + "example.com", ServerProtocol.TCP, OutputFormat.RAW, None + ) + actual = type(logger.handlers[0].formatter) + assert actual == FileEventDictToRawJSONFormatter + + +def test_get_logger_for_server_when_called_twice_only_has_one_handler(): + get_logger_for_server("example.com", ServerProtocol.TCP, OutputFormat.JSON, None) + logger = get_logger_for_server( + "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None + ) + assert len(logger.handlers) == 1 + + +def test_get_logger_for_server_uses_no_priority_syslog_handler(): + logger = get_logger_for_server( + "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None + ) + assert type(logger.handlers[0]) == NoPrioritySysLogHandler + + +def test_get_logger_for_server_constructs_handler_with_expected_args( + mocker, monkeypatch +): + no_priority_syslog_handler = mocker.patch( + "code42cli.logger.handlers.NoPrioritySysLogHandler.__init__" + ) + no_priority_syslog_handler.return_value = None + get_logger_for_server( + "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, "cert" + ) + no_priority_syslog_handler.assert_called_once_with( + "example.com", 514, ServerProtocol.TCP, "cert" + ) + + +def test_get_logger_for_server_when_hostname_includes_port_constructs_handler_with_expected_args( + mocker, +): + no_priority_syslog_handler = mocker.patch( + "code42cli.logger.handlers.NoPrioritySysLogHandler.__init__" + ) + no_priority_syslog_handler.return_value = None + get_logger_for_server( + "example.com:999", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None + ) + no_priority_syslog_handler.assert_called_once_with( + "example.com", 999, ServerProtocol.TCP, None, + ) + + +def test_get_logger_for_server_inits_socket(init_socket_mock): + get_logger_for_server( + "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None + ) + assert init_socket_mock.call_count == 1 + + +class TestCliLogger: + def test_init_creates_user_error_logger_with_expected_handlers(self): + logger = CliLogger() + handler_types = [type(h) for h in logger._logger.handlers] + assert RotatingFileHandler in handler_types + + def test_log_error_logs_expected_text_at_expected_level(self, caplog): + with caplog.at_level(logging.ERROR): + ex = Exception("TEST") + CliLogger().log_error(ex) + assert str(ex) in caplog.text + + def test_log_verbose_error_logs_expected_text_at_expected_level( + self, mocker, caplog + ): + with caplog.at_level(logging.ERROR): + request = mocker.MagicMock(sepc=Request) + request.body = {"foo": "bar"} + CliLogger().log_verbose_error("code42 dothing --flag YES", request) + assert "'code42 dothing --flag YES'" in caplog.text + assert "Request parameters: {'foo': 'bar'}" in caplog.text diff --git a/tests/test_logger.py b/tests/test_logger.py deleted file mode 100644 index 275150894..000000000 --- a/tests/test_logger.py +++ /dev/null @@ -1,156 +0,0 @@ -import logging -import os -from logging.handlers import RotatingFileHandler - -import pytest -from c42eventextractor.logging.formatters import FileEventDictToCEFFormatter -from c42eventextractor.logging.formatters import FileEventDictToJSONFormatter -from c42eventextractor.logging.formatters import FileEventDictToRawJSONFormatter -from requests import Request - -from code42cli.logger import add_handler_to_logger -from code42cli.logger import CliLogger -from code42cli.logger import get_logger_for_server -from code42cli.logger import get_view_error_details_message -from code42cli.logger import logger_has_handlers -from code42cli.util import get_user_project_path - - -@pytest.fixture -def no_priority_syslog_handler(mocker): - mock = mocker.patch( - "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.handler" - ) - - # Set handlers to empty list so it gets initialized each test - get_logger_for_server("example.com", "TCP", "CEF").handlers = [] - return mock - - -def test_add_handler_to_logger_does_as_expected(): - logger = logging.getLogger("TEST_CODE42_CLI") - formatter = logging.Formatter() - handler = logging.Handler() - add_handler_to_logger(logger, handler, formatter) - assert handler in logger.handlers - assert handler.formatter == formatter - - -def test_logger_has_handlers_when_logger_has_handlers_returns_true(): - logger = logging.getLogger("TEST_CODE42_CLI") - handler = logging.Handler() - logger.addHandler(handler) - assert logger_has_handlers(logger) - - -def test_logger_has_handlers_when_logger_does_not_have_handlers_returns_false(): - logger = logging.getLogger("TEST_CODE42_CLI") - logger.handlers = [] - assert not logger_has_handlers(logger) - - -def test_get_view_exceptions_location_message_returns_expected_message(): - actual = get_view_error_details_message() - path = os.path.join(get_user_project_path("log"), "code42_errors.log") - expected = "View details in {}".format(path) - assert actual == expected - - -def test_get_logger_for_server_has_info_level(no_priority_syslog_handler): - logger = get_logger_for_server("example.com", "TCP", "CEF") - assert logger.level == logging.INFO - - -def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter( - no_priority_syslog_handler, -): - get_logger_for_server("example.com", "TCP", "CEF") - assert ( - type(no_priority_syslog_handler.setFormatter.call_args[0][0]) - == FileEventDictToCEFFormatter - ) - - -def test_get_logger_for_server_when_given_json_format_uses_json_formatter( - no_priority_syslog_handler, -): - get_logger_for_server("example.com", "TCP", "JSON").handlers = [] - get_logger_for_server("example.com", "TCP", "JSON") - actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) - assert actual == FileEventDictToJSONFormatter - - -def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter( - no_priority_syslog_handler, -): - get_logger_for_server("example.com", "TCP", "RAW-JSON").handlers = [] - get_logger_for_server("example.com", "TCP", "RAW-JSON") - actual = type(no_priority_syslog_handler.setFormatter.call_args[0][0]) - assert actual == FileEventDictToRawJSONFormatter - - -def test_get_logger_for_server_when_called_twice_only_has_one_handler( - no_priority_syslog_handler, -): - get_logger_for_server("example.com", "TCP", "JSON") - logger = get_logger_for_server("example.com", "TCP", "CEF") - assert len(logger.handlers) == 1 - - -def test_get_logger_for_server_uses_no_priority_syslog_handler( - no_priority_syslog_handler, -): - logger = get_logger_for_server("example.com", "TCP", "CEF") - assert logger.handlers[0] == no_priority_syslog_handler - - -def test_get_logger_for_server_constructs_handler_with_expected_args( - mocker, no_priority_syslog_handler, monkeypatch -): - no_priority_syslog_handler_wrapper = mocker.patch( - "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" - ) - no_priority_syslog_handler_wrapper.return_value = None - get_logger_for_server("example.com", "TCP", "CEF") - no_priority_syslog_handler_wrapper.assert_called_once_with( - "example.com", port=514, protocol="TCP" - ) - - -def test_get_logger_for_server_when_hostname_includes_port_constructs_handler_with_expected_args( - mocker, no_priority_syslog_handler -): - no_priority_syslog_handler_wrapper = mocker.patch( - "c42eventextractor.logging.handlers.NoPrioritySysLogHandlerWrapper.__init__" - ) - no_priority_syslog_handler_wrapper.return_value = None - get_logger_for_server("example.com:999", "TCP", "CEF") - no_priority_syslog_handler_wrapper.assert_called_once_with( - "example.com", port=999, protocol="TCP" - ) - - -class TestCliLogger: - - _logger = CliLogger() - - def test_init_creates_user_error_logger_with_expected_handlers(self): - logger = CliLogger() - handler_types = [type(h) for h in logger._logger.handlers] - assert RotatingFileHandler in handler_types - - def test_log_error_logs_expected_text_at_expected_level(self, caplog): - with caplog.at_level(logging.ERROR): - ex = Exception("TEST") - self._logger.log_error(ex) - assert str(ex) in caplog.text - - def test_log_verbose_error_logs_expected_text_at_expected_level( - self, mocker, caplog - ): - with caplog.at_level(logging.ERROR): - request = mocker.MagicMock(sepc=Request) - request.body = {"foo": "bar"} - self._logger.log_verbose_error("code42 dothing --flag YES", request) - assert "'code42 dothing --flag YES'" in caplog.text - assert "Request parameters: {'foo': 'bar'}" in caplog.text diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index 4e8f7380d..b735955ea 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -1,15 +1,20 @@ import json from collections import OrderedDict +import pytest from pandas import DataFrame import code42cli.output_formats as output_formats_module +from code42cli.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP +from code42cli.output_formats import FileEventsOutputFormat +from code42cli.output_formats import FileEventsOutputFormatter +from code42cli.output_formats import to_cef TEST_DATA = [ { "type$": "RULE_METADATA", - "modifiedBy": "test.user+partners@code42.com", + "modifiedBy": "test.user+partners@example.com", "modifiedAt": "2020-06-22T16:26:16.3875180Z", "name": "outside td", "description": "", @@ -21,12 +26,12 @@ "observerRuleId": "d12d54f0-5160-47a8-a48f-7d5fa5b051c5", "type": "FED_CLOUD_SHARE_PERMISSIONS", "id": "5157f1df-cb3e-4755-92a2-0f42c7841020", - "createdBy": "test.user+partners@code42.com", + "createdBy": "test.user+partners@example.com", "createdAt": "2020-06-22T16:26:16.3875180Z", }, { "type$": "RULE_METADATA", - "modifiedBy": "testuser@code42.com", + "modifiedBy": "testuser@example.com", "modifiedAt": "2020-07-16T08:09:44.4345110Z", "name": "Test different filters", "description": "Test different filters", @@ -38,12 +43,12 @@ "observerRuleId": "8b393324-c34c-44ac-9f79-4313601dd859", "type": "FED_ENDPOINT_EXFILTRATION", "id": "88354829-0958-4d60-a20d-69a53cf603b6", - "createdBy": "test.user+partners@code42.com", + "createdBy": "test.user+partners@example.com", "createdAt": "2020-05-20T11:56:41.2324240Z", }, { "type$": "RULE_METADATA", - "modifiedBy": "testuser@code42.com", + "modifiedBy": "testuser@example.com", "modifiedAt": "2020-05-28T16:19:19.5250970Z", "name": "Test Alerts using CLI", "description": "user", @@ -55,7 +60,7 @@ "observerRuleId": "5eabed1d-a406-4dfc-af81-f7485ee09b19", "type": "FED_ENDPOINT_EXFILTRATION", "id": "b2cb33e6-6683-4822-be1d-8de5ef87728e", - "createdBy": "testuser@code42.com", + "createdBy": "testuser@example.com", "createdAt": "2020-05-18T11:47:16.6109560Z", }, ] @@ -82,9 +87,9 @@ CSV_OUTPUT = """type$,modifiedBy,modifiedAt,name,description,severity,isSystem,isEnabled,ruleSource,tenantId,observerRuleId,type,id,createdBy,createdAt\r -RULE_METADATA,test.user+partners@code42.com,2020-06-22T16:26:16.3875180Z,outside td,,HIGH,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,d12d54f0-5160-47a8-a48f-7d5fa5b051c5,FED_CLOUD_SHARE_PERMISSIONS,5157f1df-cb3e-4755-92a2-0f42c7841020,test.user+partners@code42.com,2020-06-22T16:26:16.3875180Z\r -RULE_METADATA,testuser@code42.com,2020-07-16T08:09:44.4345110Z,Test different filters,Test different filters,MEDIUM,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,8b393324-c34c-44ac-9f79-4313601dd859,FED_ENDPOINT_EXFILTRATION,88354829-0958-4d60-a20d-69a53cf603b6,test.user+partners@code42.com,2020-05-20T11:56:41.2324240Z\r -RULE_METADATA,testuser@code42.com,2020-05-28T16:19:19.5250970Z,Test Alerts using CLI,user,HIGH,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,5eabed1d-a406-4dfc-af81-f7485ee09b19,FED_ENDPOINT_EXFILTRATION,b2cb33e6-6683-4822-be1d-8de5ef87728e,testuser@code42.com,2020-05-18T11:47:16.6109560Z\r +RULE_METADATA,test.user+partners@example.com,2020-06-22T16:26:16.3875180Z,outside td,,HIGH,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,d12d54f0-5160-47a8-a48f-7d5fa5b051c5,FED_CLOUD_SHARE_PERMISSIONS,5157f1df-cb3e-4755-92a2-0f42c7841020,test.user+partners@example.com,2020-06-22T16:26:16.3875180Z\r +RULE_METADATA,testuser@example.com,2020-07-16T08:09:44.4345110Z,Test different filters,Test different filters,MEDIUM,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,8b393324-c34c-44ac-9f79-4313601dd859,FED_ENDPOINT_EXFILTRATION,88354829-0958-4d60-a20d-69a53cf603b6,test.user+partners@example.com,2020-05-20T11:56:41.2324240Z\r +RULE_METADATA,testuser@example.com,2020-05-28T16:19:19.5250970Z,Test Alerts using CLI,user,HIGH,False,True,Alerting,1d71796f-af5b-4231-9d8e-df6434da4663,5eabed1d-a406-4dfc-af81-f7485ee09b19,FED_ENDPOINT_EXFILTRATION,b2cb33e6-6683-4822-be1d-8de5ef87728e,testuser@example.com,2020-05-18T11:47:16.6109560Z\r """ @@ -102,6 +107,122 @@ "id": "5157f1df-cb3e-4755-92a2-0f42c7841020", } +AED_CLOUD_ACTIVITY_EVENT_DICT = json.loads( + """{ + "url": "https://www.example.com", + "syncDestination": "TEST_SYNC_DESTINATION", + "sharedWith": [{"cloudUsername": "example1@example.com"}, {"cloudUsername": "example2@example.com"}], + "cloudDriveId": "TEST_CLOUD_DRIVE_ID", + "actor": "actor@example.com", + "tabUrl": "TEST_TAB_URL", + "windowTitle": "TEST_WINDOW_TITLE" + }""" +) + + +AED_REMOVABLE_MEDIA_EVENT_DICT = json.loads( + """{ + "removableMediaVendor": "TEST_VENDOR_NAME", + "removableMediaName": "TEST_NAME", + "removableMediaSerialNumber": "TEST_SERIAL_NUMBER", + "removableMediaCapacity": 5000000, + "removableMediaBusType": "TEST_BUS_TYPE" + }""" +) + + +AED_EMAIL_EVENT_DICT = json.loads( + """{ + "emailSender": "TEST_EMAIL_SENDER", + "emailRecipients": ["test.recipient1@example.com", "test.recipient2@example.com"] + }""" +) + + +AED_EVENT_DICT = json.loads( + """{ + "eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16", + "eventType": "READ_BY_APP", + "eventTimestamp": "2019-09-09T02:42:23.851Z", + "insertionTimestamp": "2019-09-09T22:47:42.724Z", + "filePath": "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/", + "fileName": "InfoPlist.strings", + "fileType": "FILE", + "fileCategory": "UNCATEGORIZED", + "fileSize": 86, + "fileOwner": "testtesterson", + "md5Checksum": "19b92e63beb08c27ab4489fcfefbbe44", + "sha256Checksum": "2e0677355c37fa18fd20d372c7420b8b34de150c5801910c3bbb1e8e04c727ef", + "createTimestamp": "2012-07-22T02:19:29Z", + "modifyTimestamp": "2012-12-19T03:00:08Z", + "deviceUserName": "test.testerson+testair@example.com", + "osHostName": "Test's MacBook Air", + "domainName": "192.168.0.3", + "publicIpAddress": "71.34.4.22", + "privateIpAddresses": [ + "fe80:0:0:0:f053:a9bd:973:6c8c%utun1", + "fe80:0:0:0:a254:cb31:8840:9d6b%utun0", + "0:0:0:0:0:0:0:1%lo0", + "192.168.0.3", + "fe80:0:0:0:0:0:0:1%lo0", + "fe80:0:0:0:8c28:1ac9:5745:a6e7%utun3", + "fe80:0:0:0:2e4a:351c:bb9b:2f28%utun2", + "fe80:0:0:0:6df:855c:9436:37f8%utun4", + "fe80:0:0:0:ce:5072:e5f:7155%en0", + "fe80:0:0:0:b867:afff:fefc:1a82%awdl0", + "127.0.0.1" + ], + "deviceUid": "912339407325443353", + "userUid": "912338501981077099", + "actor": null, + "directoryId": [], + "source": "Endpoint", + "url": null, + "shared": null, + "sharedWith": [], + "sharingTypeAdded": [], + "cloudDriveId": null, + "detectionSourceAlias": null, + "fileId": null, + "exposure": [ + "ApplicationRead" + ], + "processOwner": "testtesterson", + "processName": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "removableMediaVendor": null, + "removableMediaName": null, + "removableMediaSerialNumber": null, + "removableMediaCapacity": null, + "removableMediaBusType": null, + "syncDestination": null + }""" +) + + +@pytest.fixture +def mock_file_event_removable_media_event(): + return AED_REMOVABLE_MEDIA_EVENT_DICT + + +@pytest.fixture +def mock_file_event_cloud_activity_event(): + return AED_CLOUD_ACTIVITY_EVENT_DICT + + +@pytest.fixture +def mock_file_event_email_event(): + return AED_EMAIL_EVENT_DICT + + +@pytest.fixture +def mock_file_event(): + return AED_EVENT_DICT + + +@pytest.fixture +def mock_to_cef(mocker): + return mocker.patch("code42cli.output_formats.to_cef") + def assert_csv_texts_are_equal(actual, expected): """Have to be careful when testing ordering because of 3.5""" @@ -135,7 +256,7 @@ def test_to_table_formats_when_given_no_output_returns_none(): def test_to_table_when_not_given_header_creates_header_dynamically(): formatted_output = output_formats_module.to_table(TEST_DATA, None) assert len(formatted_output) > len(TABLE_OUTPUT) - assert "test.user+partners@code42.com" in formatted_output + assert "test.user+partners@example.com" in formatted_output def test_to_json(): @@ -194,6 +315,461 @@ def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed mock_to_table.assert_called_once_with("TEST", None) +class TestFileEventsOutputFormatter: + def test_init_sets_format_func_to_dynamic_csv_function_when_csv_option_is_passed( + self, mock_to_csv + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CSV) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_csv.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_formatted_json_function_when_json__option_is_passed( + self, mock_to_formatted_json + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.JSON) + for _ in formatter.get_formatted_output(["TEST"]): + pass + mock_to_formatted_json.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_passed( + self, mock_to_json + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.RAW) + for _ in formatter.get_formatted_output(["TEST"]): + pass + mock_to_json.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_cef_function_when_cef_format_option_is_passed( + self, mock_to_cef + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CEF) + for _ in formatter.get_formatted_output(["TEST"]): + pass + mock_to_cef.assert_called_once_with("TEST") + + def test_init_sets_format_func_to_table_function_when_table_format_option_is_passed( + self, mock_to_table + ): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.TABLE) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_table.assert_called_once_with("TEST", None) + + def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed( + self, mock_to_table + ): + formatter = FileEventsOutputFormatter(None) + for _ in formatter.get_formatted_output("TEST"): + pass + mock_to_table.assert_called_once_with("TEST", None) + + +def test_to_cef_returns_cef_tagged_string(mock_file_event): + cef_out = to_cef(mock_file_event) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[0] == "CEF:0" + + +def test_to_cef_uses_correct_vendor_name(mock_file_event): + cef_out = to_cef(mock_file_event) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[1] == "Code42" + + +def test_to_cef_uses_correct_default_product_name(mock_file_event): + cef_out = to_cef(mock_file_event) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[2] == "Advanced Exfiltration Detection" + + +def test_to_cef_uses_correct_default_severity(mock_file_event): + cef_out = to_cef(mock_file_event) + cef_parts = get_cef_parts(cef_out) + assert cef_parts[6] == "5" + + +def test_to_cef_excludes_none_values_from_output(mock_file_event): + cef_out = to_cef(mock_file_event) + cef_parts = get_cef_parts(cef_out) + assert "=None " not in cef_parts[-1] + + +def test_to_cef_excludes_empty_values_from_output(mock_file_event): + cef_out = to_cef(mock_file_event) + cef_parts = get_cef_parts(cef_out) + assert "= " not in cef_parts[-1] + + +def test_to_cef_excludes_file_event_fields_not_in_cef_map(mock_file_event): + test_value = "definitelyExcludedValue" + mock_file_event["unmappedFieldName"] = test_value + cef_out = to_cef(mock_file_event) + cef_parts = get_cef_parts(cef_out) + del mock_file_event["unmappedFieldName"] + assert test_value not in cef_parts[-1] + + +def test_to_cef_includes_os_hostname_if_present(mock_file_event): + expected_field_name = "shost" + expected_value = "Test's MacBook Air" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_public_ip_address_if_present(mock_file_event): + expected_field_name = "src" + expected_value = "71.34.4.22" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_user_uid_if_present(mock_file_event): + expected_field_name = "suid" + expected_value = "912338501981077099" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_device_username_if_present(mock_file_event): + expected_field_name = "suser" + expected_value = "test.testerson+testair@example.com" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_capacity_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cn1" + expected_value = "5000000" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_capacity_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cn1Label" + expected_value = "Code42AEDRemovableMediaCapacity" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_bus_type_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs1" + expected_value = "TEST_BUS_TYPE" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_bus_type_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs1Label" + expected_value = "Code42AEDRemovableMediaBusType" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_vendor_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs2" + expected_value = "TEST_VENDOR_NAME" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_vendor_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs2Label" + expected_value = "Code42AEDRemovableMediaVendor" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_name_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs3" + expected_value = "TEST_NAME" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_name_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs3Label" + expected_value = "Code42AEDRemovableMediaName" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_serial_number_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs4" + expected_value = "TEST_SERIAL_NUMBER" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_removable_media_serial_number_label_if_present( + mock_file_event_removable_media_event, +): + expected_field_name = "cs4Label" + expected_value = "Code42AEDRemovableMediaSerialNumber" + cef_out = to_cef(mock_file_event_removable_media_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_actor_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "suser" + expected_value = "actor@example.com" + cef_out = to_cef(mock_file_event_cloud_activity_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_sync_destination_if_present( + mock_file_event_cloud_activity_event, +): + expected_field_name = "destinationServiceName" + expected_value = "TEST_SYNC_DESTINATION" + cef_out = to_cef(mock_file_event_cloud_activity_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_event_timestamp_if_present(mock_file_event): + expected_field_name = "end" + expected_value = "1567996943851" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_create_timestamp_if_present(mock_file_event): + expected_field_name = "fileCreateTime" + expected_value = "1342923569000" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_md5_checksum_if_present(mock_file_event): + expected_field_name = "fileHash" + expected_value = "19b92e63beb08c27ab4489fcfefbbe44" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_modify_timestamp_if_present(mock_file_event): + expected_field_name = "fileModificationTime" + expected_value = "1355886008000" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_file_path_if_present(mock_file_event): + expected_field_name = "filePath" + expected_value = "/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_file_name_if_present(mock_file_event): + expected_field_name = "fname" + expected_value = "InfoPlist.strings" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_file_size_if_present(mock_file_event): + expected_field_name = "fsize" + expected_value = "86" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_file_category_if_present(mock_file_event): + expected_field_name = "fileType" + expected_value = "UNCATEGORIZED" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_exposure_if_present(mock_file_event): + expected_field_name = "reason" + expected_value = "ApplicationRead" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_url_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "filePath" + expected_value = "https://www.example.com" + cef_out = to_cef(mock_file_event_cloud_activity_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_insertion_timestamp_if_present(mock_file_event): + expected_field_name = "rt" + expected_value = "1568069262724" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_process_name_if_present(mock_file_event): + expected_field_name = "sproc" + expected_value = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_event_id_if_present(mock_file_event): + expected_field_name = "externalId" + expected_value = "0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_device_uid_if_present(mock_file_event): + expected_field_name = "deviceExternalId" + expected_value = "912339407325443353" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_domain_name_if_present(mock_file_event): + expected_field_name = "dvchost" + expected_value = "192.168.0.3" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_source_if_present(mock_file_event): + expected_field_name = "sourceServiceName" + expected_value = "Endpoint" + cef_out = to_cef(mock_file_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_cloud_drive_id_if_present( + mock_file_event_cloud_activity_event, +): + expected_field_name = "aid" + expected_value = "TEST_CLOUD_DRIVE_ID" + cef_out = to_cef(mock_file_event_cloud_activity_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_shared_with_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "duser" + expected_value = "example1@example.com,example2@example.com" + cef_out = to_cef(mock_file_event_cloud_activity_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_tab_url_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "request" + expected_value = "TEST_TAB_URL" + cef_out = to_cef(mock_file_event_cloud_activity_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_window_title_if_present(mock_file_event_cloud_activity_event,): + expected_field_name = "requestClientApplication" + expected_value = "TEST_WINDOW_TITLE" + cef_out = to_cef(mock_file_event_cloud_activity_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_email_recipients_if_present(mock_file_event_email_event,): + expected_field_name = "duser" + expected_value = "test.recipient1@example.com,test.recipient2@example.com" + cef_out = to_cef(mock_file_event_email_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_email_sender_if_present(mock_file_event_email_event,): + expected_field_name = "suser" + expected_value = "TEST_EMAIL_SENDER" + cef_out = to_cef(mock_file_event_email_event) + assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_created( + mock_file_event, +): + event_type = "CREATED" + mock_file_event["eventType"] = event_type + cef_out = to_cef(mock_file_event) + assert event_name_assigned_correct_signature_id(event_type, "C42200", cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_modified( + mock_file_event, +): + event_type = "MODIFIED" + mock_file_event["eventType"] = event_type + cef_out = to_cef(mock_file_event) + assert event_name_assigned_correct_signature_id(event_type, "C42201", cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_deleted( + mock_file_event, +): + event_type = "DELETED" + mock_file_event["eventType"] = event_type + cef_out = to_cef(mock_file_event) + assert event_name_assigned_correct_signature_id(event_type, "C42202", cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_read_by_app( + mock_file_event, +): + event_type = "READ_BY_APP" + mock_file_event["eventType"] = event_type + cef_out = to_cef(mock_file_event) + assert event_name_assigned_correct_signature_id(event_type, "C42203", cef_out) + + +def test_to_cef_includes_correct_event_name_and_signature_id_for_emailed( + mock_file_event_email_event, +): + event_type = "EMAILED" + mock_file_event_email_event["eventType"] = event_type + cef_out = to_cef(mock_file_event_email_event) + assert event_name_assigned_correct_signature_id(event_type, "C42204", cef_out) + + +def get_cef_parts(cef_str): + return cef_str.split("|") + + +def key_value_pair_in_cef_extension(field_name, field_value, cef_str): + cef_parts = get_cef_parts(cef_str) + kvp = "{}={}".format(field_name, field_value) + return kvp in cef_parts[-1] + + +def event_name_assigned_correct_signature_id(event_name, signature_id, cef_out): + if event_name in FILE_EVENT_TO_SIGNATURE_ID_MAP: + cef_parts = get_cef_parts(cef_out) + return cef_parts[4] == signature_id and cef_parts[5] == event_name + + return False + + +def test_security_data_output_format_has_expected_options(): + options = FileEventsOutputFormat() + actual = list(options) + expected = ["CEF", "CSV", "RAW-JSON", "JSON", "TABLE"] + assert set(actual) == set(expected) + + class TestDataFrameOutputFormatter: def test_init_sets_format_func_to_formatted_json_function_when_json_format_option_is_passed( self, mock_dataframe_to_json From 18f00821600c966843b9d62aa091b8da583eedfe Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 3 Feb 2021 09:16:44 +0530 Subject: [PATCH 184/349] Integration tests for send-to commands. (#212) * Added docker and syslog server setup for send-to commands * intermittent commit * integration-tests-data-transfer * add send-to commands in other features * use ncat command to create server * fix style * fix git rebase error * Added docker and syslog server setup for send-to commands * intermittent commit * integration-tests-data-transfer * add send-to commands in other features * use ncat command to create server * fix style * fix git rebase error * use cli runner for integration tests * use cli runner in other integration tests * renamed command option to its new name * use cli runner for legalhold * fix style * removed command_runner * renamed method * remove pexpect dependency * Added dependency note for testing send-to commands. * append profile after the command * fix style * remove unnecessary state object, delete profile first in case stored creds are bad, fail fast on profile creation errors * unused import * dangling comma * remove obj and duplicate append_profile * remove obj from assert_test_is_successful * update assert_test_is_successful usage, remove last obj usages * rename test method names to appropriately * make parameterized options more readable * removed all filter options and choice options from integration tests Co-authored-by: Tim Abramson --- CONTRIBUTING.md | 2 + tests/conftest.py | 2 +- tests/integration/conftest.py | 60 +++++++--------------- tests/integration/test_alert_rules.py | 33 ++++++------ tests/integration/test_alerts.py | 70 +++++++++++++------------- tests/integration/test_auditlogs.py | 68 ++++++++++++------------- tests/integration/test_cases.py | 34 +++---------- tests/integration/test_legal_hold.py | 34 ++++++------- tests/integration/test_securitydata.py | 26 ++++++++++ tests/integration/util.py | 29 ++++++++++- tox.ini | 1 - 11 files changed, 179 insertions(+), 180 deletions(-) create mode 100644 tests/integration/test_securitydata.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f4502e56..295b08529 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,6 +130,8 @@ If you want to run the integration tests in your current python environment, you pytest -m "integration" ``` +Integration tests have a dependency on `nmap` module to test `send-to` commands. + ### Writing tests Put actual before expected values in assert statements. Pytest assumes this order. diff --git a/tests/conftest.py b/tests/conftest.py index d4ef1d0b7..3bc45e433 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ TEST_ID = "TEST_ID" -@pytest.fixture +@pytest.fixture(scope="session") def runner(): return CliRunner() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index bea8da11b..344ad95f6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,14 +1,12 @@ import os -from contextlib import contextmanager +from shlex import split as split_command -import pexpect import pytest from code42cli.errors import Code42CLIError -from code42cli.profile import create_profile -from code42cli.profile import delete_profile +from code42cli.main import cli from code42cli.profile import get_profile -from code42cli.profile import switch_default_profile + TEST_PROFILE_NAME = "TEMP-INTEGRATION-TEST" _LINE_FEED = b"\r\n" @@ -16,21 +14,22 @@ _ENCODING_TYPE = "utf-8" -@contextmanager -def use_temp_profile(): +@pytest.fixture(scope="session") +def integration_test_profile(runner): """Creates a temporary profile to use for executing integration tests.""" host = os.environ.get("C42_HOST") or "http://127.0.0.1:4200" username = os.environ.get("C42_USER") or "test_username@example.com" password = os.environ.get("C42_PW") or "test_password" - current_profile_name = _get_current_profile_name() - create_profile(TEST_PROFILE_NAME, host, username, True) - switch_default_profile(TEST_PROFILE_NAME) - yield password - delete_profile(TEST_PROFILE_NAME) - - # Switch back to the original profile if there was one - if current_profile_name: - switch_default_profile(current_profile_name) + delete_test_profile = split_command(f"profile delete {TEST_PROFILE_NAME} -y") + create_test_profile = split_command( + f"profile create -n {TEST_PROFILE_NAME} -u {username} -s {host} --password {password} -y" + ) + runner.invoke(cli, delete_test_profile) + result = runner.invoke(cli, create_test_profile) + if result.exit_code != 0: + pytest.exit(result.output) + yield + runner.invoke(cli, delete_test_profile) def _get_current_profile_name(): @@ -41,30 +40,9 @@ def _get_current_profile_name(): return None -@pytest.fixture -def command_runner(): - def run_command(command): - with use_temp_profile() as pw: - process = pexpect.spawn(command) - response = [] - try: - expected = process.expect([_PASSWORD_PROMPT, pexpect.EOF]) - if expected == 0: - process.sendline(pw) - process.expect(_LINE_FEED) - output = process.readlines() - response = [_encode_response(line) for line in output] - else: - output = process.before - response = _encode_response(output).splitlines() - except pexpect.TIMEOUT: - process.close() - return process.exitstatus, response - process.close() - return process.exitstatus, response - - return run_command - - def _encode_response(line, encoding_type=_ENCODING_TYPE): return line.decode(encoding_type) + + +def append_profile(command): + return "{} --profile {}".format(command, TEST_PROFILE_NAME) diff --git a/tests/integration/test_alert_rules.py b/tests/integration/test_alert_rules.py index 4d174b094..f915baf07 100644 --- a/tests/integration/test_alert_rules.py +++ b/tests/integration/test_alert_rules.py @@ -1,24 +1,19 @@ import pytest +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful -ALERT_RULES_COMMAND = "code42 alert-rules" + +@pytest.mark.integration +def test_alert_rules_list_command_returns_success_return_code( + runner, integration_test_profile +): + command = "alert-rules list" + assert_test_is_successful(runner, append_profile(command)) @pytest.mark.integration -@pytest.mark.parametrize( - "command", - [ - "{} list".format(ALERT_RULES_COMMAND), - "{} show test-rule-id".format(ALERT_RULES_COMMAND), - "{} list -f CSV".format(ALERT_RULES_COMMAND), - "{} list -f TABLE".format(ALERT_RULES_COMMAND), - "{} list -f RAW-JSON".format(ALERT_RULES_COMMAND), - "{} list -f JSON".format(ALERT_RULES_COMMAND), - "{} list --format CSV".format(ALERT_RULES_COMMAND), - "{} list --format TABLE".format(ALERT_RULES_COMMAND), - "{} list --format JSON".format(ALERT_RULES_COMMAND), - "{} list --format RAW-JSON".format(ALERT_RULES_COMMAND), - ], -) -def test_alert_rules_command_returns_success_return_code(command, command_runner): - return_code, response = command_runner(command) - assert return_code == 0 +def test_alert_rules_show_command_returns_success_return_code( + runner, integration_test_profile +): + command = ("alert-rules show test-rule-id",) + assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_alerts.py b/tests/integration/test_alerts.py index 04fa99915..d790126b1 100644 --- a/tests/integration/test_alerts.py +++ b/tests/integration/test_alerts.py @@ -1,52 +1,50 @@ from datetime import datetime from datetime import timedelta +from shlex import split as split_command import pytest +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful +from tests.integration.util import DataServer +from code42cli.main import cli begin_date = datetime.utcnow() - timedelta(days=20) end_date = datetime.utcnow() - timedelta(days=10) begin_date_str = begin_date.strftime("%Y-%m-%d") end_date_str = end_date.strftime("%Y-%m-%d") -ALERT_SEARCH_COMMAND = "code42 alerts search -b {} -e {}".format( - begin_date_str, end_date_str -) -ADVANCED_QUERY = """{"groupClause":"AND", "groups":[{"filterClause":"AND", -"filters":[{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"2020-09-13T00:00:00.000Z"}, -{"operator":"ON_OR_BEFORE", "term":"eventTimestamp", "value":"2020-12-07T13:20:15.195Z"}]}], -"srtDir":"asc", "srtKey":"eventId", "pgNum":1, "pgSize":10000} -""" -ALERT_ADVANCED_QUERY_COMMAND = "code42 alerts search --advanced-query '{}'".format( - ADVANCED_QUERY -) + +@pytest.mark.integration +def test_alerts_search_command_returns_success_return_code( + runner, integration_test_profile +): + command = "alerts search -b {} -e {}".format(begin_date_str, end_date_str) + assert_test_is_successful(runner, append_profile(command)) @pytest.mark.integration @pytest.mark.parametrize( - "command", - [ - ALERT_SEARCH_COMMAND, - "{} --state OPEN".format(ALERT_SEARCH_COMMAND), - "{} --state RESOLVED".format(ALERT_SEARCH_COMMAND), - "{} --actor user@code42.com".format(ALERT_SEARCH_COMMAND), - "{} --rule-name 'File Upload Alert'".format(ALERT_SEARCH_COMMAND), - "{} --rule-id 962a6a1c-54f6-4477-90bd-a08cc74cbf71".format( - ALERT_SEARCH_COMMAND - ), - "{} --rule-type FedEndpointExfiltration".format(ALERT_SEARCH_COMMAND), - "{} --description 'Alert on any file upload'".format(ALERT_SEARCH_COMMAND), - "{} --exclude-rule-type 'FedEndpointExfiltration'".format(ALERT_SEARCH_COMMAND), - "{} --exclude-rule-id '962a6a1c-54f6-4477-90bd-a08cc74cbf71'".format( - ALERT_SEARCH_COMMAND - ), - "{} --exclude-rule-name 'File Upload Alert'".format(ALERT_SEARCH_COMMAND), - "{} --exclude-actor-contains 'user@code42.com'".format(ALERT_SEARCH_COMMAND), - "{} --exclude-actor 'user@code42.com'".format(ALERT_SEARCH_COMMAND), - "{} --actor-contains 'user@code42.com'".format(ALERT_SEARCH_COMMAND), - ALERT_ADVANCED_QUERY_COMMAND, - ], + "protocol", ["TCP", "UDP"], ) -def test_alert_command_returns_success_return_code(command, command_runner): - return_code, response = command_runner(command) - assert return_code == 0 +def test_alerts_send_to_returns_success_return_code( + runner, integration_test_profile, protocol +): + command = "alerts send-to localhost:5140 -p {} -b {}".format( + protocol, begin_date_str + ) + with DataServer(protocol=protocol): + result = runner.invoke(cli, split_command(append_profile(command))) + assert result.exit_code == 0 + + +def test_alerts_advanced_query_returns_success_return_code( + runner, integration_test_profile +): + ADVANCED_QUERY = """{"groupClause":"AND", "groups":[{"filterClause":"AND", + "filters":[{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"2020-09-13T00:00:00.000Z"}, + {"operator":"ON_OR_BEFORE", "term":"eventTimestamp", "value":"2020-12-07T13:20:15.195Z"}]}], + "srtDir":"asc", "srtKey":"eventId", "pgNum":1, "pgSize":10000} + """ + command = "alerts search --advanced-query '{}'".format(ADVANCED_QUERY) + assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_auditlogs.py b/tests/integration/test_auditlogs.py index 921aa2dc2..3ad3e4be4 100644 --- a/tests/integration/test_auditlogs.py +++ b/tests/integration/test_auditlogs.py @@ -1,46 +1,46 @@ from datetime import datetime from datetime import timedelta +from shlex import split as split_command import pytest +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful +from tests.integration.util import DataServer -SEARCH_COMMAND = "code42 audit-logs search" -BASE_COMMAND = "{} -b".format(SEARCH_COMMAND) -begin_date = datetime.utcnow() - timedelta(days=-10) +from code42cli.main import cli + + +begin_date = datetime.utcnow() - timedelta(days=2) begin_date_str = begin_date.strftime("%Y-%m-%d %H:%M:%S") -end_date = datetime.utcnow() - timedelta(days=10) +end_date = datetime.utcnow() - timedelta(days=0) end_date_str = end_date.strftime("%Y-%m-%d %H:%M:%S") @pytest.mark.integration @pytest.mark.parametrize( - "command", - [ - ("{} '{}'".format(BASE_COMMAND, begin_date_str)), - ("{} '{}' -e '{}'".format(BASE_COMMAND, begin_date_str, end_date_str)), - ("{} '{}' --end '{}'".format(BASE_COMMAND, begin_date_str, end_date_str)), - ("{} '{}' --event-type '{}'".format(BASE_COMMAND, begin_date_str, "test")), - ("{} '{}' --username '{}'".format(BASE_COMMAND, begin_date_str, "test")), - ("{} '{}' --user-id '{}'".format(BASE_COMMAND, begin_date_str, "123")), - ("{} '{}' --user-ip '{}'".format(BASE_COMMAND, begin_date_str, "0.0.0.0")), - ("{} '{}' --affected-user-id '{}'".format(BASE_COMMAND, begin_date_str, "123")), - ( - "{} '{}' --affected-username '{}'".format( - BASE_COMMAND, begin_date_str, "test" - ) - ), - ("{} '{}' -f {}".format(BASE_COMMAND, begin_date_str, "CSV")), - ("{} '{}' -f '{}'".format(BASE_COMMAND, begin_date_str, "TABLE")), - ("{} '{}' -f '{}'".format(BASE_COMMAND, begin_date_str, "JSON")), - ("{} '{}' -f '{}'".format(BASE_COMMAND, begin_date_str, "RAW-JSON")), - ("{} '{}' --format {}".format(BASE_COMMAND, begin_date_str, "CSV")), - ("{} '{}' --format '{}'".format(BASE_COMMAND, begin_date_str, "TABLE")), - ("{} '{}' --format '{}'".format(BASE_COMMAND, begin_date_str, "JSON")), - ("{} '{}' --format '{}'".format(BASE_COMMAND, begin_date_str, "RAW-JSON")), - ("{} --begin '{}'".format(SEARCH_COMMAND, begin_date_str)), - ("{} '{}' -d".format(BASE_COMMAND, begin_date_str)), - ("{} '{}' --debug".format(BASE_COMMAND, begin_date_str)), - ], + "protocol", ["TCP", "UDP"], ) -def test_auditlogs_search_command_returns_success_return_code(command, command_runner): - return_code, response = command_runner(command) - assert return_code == 0 +def test_auditlogs_send_to_command_returns_success_return_code( + runner, integration_test_profile, protocol +): + command = "audit-logs send-to localhost:5140 -p {} -b '{}'".format( + protocol, begin_date_str + ) + with DataServer(protocol=protocol): + result = runner.invoke(cli, split_command(append_profile(command))) + assert result.exit_code == 0 + + +@pytest.mark.integration +def test_auditlogs_search_command_with_short_hand_begin_returns_success_return_code( + runner, integration_test_profile +): + command = "audit-logs search -b '{}'".format(begin_date_str) + assert_test_is_successful(runner, append_profile(command)) + + +def test_auditlogs_search_command_with_full_begin_returns_success_return_code( + runner, integration_test_profile, +): + command = "audit-logs search --begin '{}'".format(begin_date_str) + assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_cases.py b/tests/integration/test_cases.py index b817d09c1..6fab7e9d0 100644 --- a/tests/integration/test_cases.py +++ b/tests/integration/test_cases.py @@ -1,31 +1,11 @@ import pytest -from tests.integration import run_command - -CASES_COMMAND = "code42 cases" +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful @pytest.mark.integration -@pytest.mark.parametrize( - "command", - [ - "{} list".format(CASES_COMMAND), - "{} list -f TABLE".format(CASES_COMMAND), - "{} list -f RAW-JSON".format(CASES_COMMAND), - "{} list -f JSON".format(CASES_COMMAND), - "{} list --format CSV".format(CASES_COMMAND), - "{} list --format TABLE".format(CASES_COMMAND), - "{} list --format JSON".format(CASES_COMMAND), - "{} list --format RAW-JSON".format(CASES_COMMAND), - "{} list --assignee 123".format(CASES_COMMAND), - "{} list --status OPEN".format(CASES_COMMAND), - "{} list --subject 123".format(CASES_COMMAND), - "{} list --begin-create-time 2021-01-01".format(CASES_COMMAND), - "{} list --end-create-time 2021-01-01".format(CASES_COMMAND), - "{} list --begin-update-time 2021-01-01".format(CASES_COMMAND), - "{} list --end-update-time 2021-01-01".format(CASES_COMMAND), - "{} list --name test".format(CASES_COMMAND), - ], -) -def test_alert_rules_command_returns_success_return_code(command): - return_code, response = run_command(command) - assert return_code == 0 +def test_cases_list_command_returns_success_return_code( + runner, integration_test_profile +): + command = "cases list" + assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_legal_hold.py b/tests/integration/test_legal_hold.py index ac32826aa..0b4603f60 100644 --- a/tests/integration/test_legal_hold.py +++ b/tests/integration/test_legal_hold.py @@ -1,24 +1,18 @@ import pytest - -LEGAL_HOLD_COMMAND = "code42 legal-hold" +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful @pytest.mark.integration -@pytest.mark.parametrize( - "command", - [ - "{} list".format(LEGAL_HOLD_COMMAND), - "{} show 984140047896012577".format(LEGAL_HOLD_COMMAND), - "{} list -f CSV".format(LEGAL_HOLD_COMMAND), - "{} list -f TABLE".format(LEGAL_HOLD_COMMAND), - "{} list -f RAW-JSON".format(LEGAL_HOLD_COMMAND), - "{} list -f JSON".format(LEGAL_HOLD_COMMAND), - "{} list --format CSV".format(LEGAL_HOLD_COMMAND), - "{} list --format TABLE".format(LEGAL_HOLD_COMMAND), - "{} list --format JSON".format(LEGAL_HOLD_COMMAND), - "{} list --format RAW-JSON".format(LEGAL_HOLD_COMMAND), - ], -) -def test_alert_rules_command_returns_success_return_code(command, command_runner): - return_code, response = command_runner(command) - assert return_code == 0 +def test_legal_hold_list_command_returns_success_return_code( + runner, integration_test_profile +): + command = "legal-hold list" + assert_test_is_successful(runner, append_profile(command)) + + +def test_legal_hold_show_command_returns_success_return_code( + runner, integration_test_profile +): + command = ("legal-hold show 984140047896012577",) + assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_securitydata.py b/tests/integration/test_securitydata.py new file mode 100644 index 000000000..9d89ffc44 --- /dev/null +++ b/tests/integration/test_securitydata.py @@ -0,0 +1,26 @@ +from datetime import datetime +from datetime import timedelta +from shlex import split as split_command + +import pytest +from tests.integration.conftest import append_profile +from tests.integration.util import DataServer + +from code42cli.main import cli + + +@pytest.mark.integration +@pytest.mark.parametrize( + "protocol", ["TCP", "UDP"], +) +def test_security_data_send_to_return_success_return_code( + runner, integration_test_profile, protocol +): + begin_date = datetime.utcnow() - timedelta(days=20) + begin_date_str = begin_date.strftime("%Y-%m-%d") + command = "security-data send-to localhost:5140 -p {} -b {}".format( + protocol, begin_date_str + ) + with DataServer(protocol=protocol): + result = runner.invoke(cli, split_command(append_profile(command))) + assert result.exit_code == 0 diff --git a/tests/integration/util.py b/tests/integration/util.py index 74dd7afc9..25979093c 100644 --- a/tests/integration/util.py +++ b/tests/integration/util.py @@ -1,4 +1,8 @@ import os +import subprocess +from shlex import split as split_command + +from code42cli.main import cli class cleanup: @@ -17,7 +21,7 @@ def cleanup_after_validation(filename): execution. The decorated function should return validation function that takes the content of the file - as input. e.g `test_alerts.py::test_alert_writes_to_file_and_filters_result_by_severity` + as input. e.g """ def wrap(test_function): @@ -30,3 +34,26 @@ def wrapper(): return wrapper return wrap + + +class DataServer: + TCP_SERVER_COMMAND = "ncat -l 5140" + UDP_SERVER_COMMAND = "ncat -ul 5140" + + def __init__(self, protocol="TCP"): + if protocol.upper() == "UDP": + self.command = DataServer.UDP_SERVER_COMMAND + else: + self.command = DataServer.TCP_SERVER_COMMAND + self.process = None + + def __enter__(self): + self.process = subprocess.Popen(self.command.split(" ")) + + def __exit__(self, exc_type, exc_val, exc_tb): + self.process.kill() + + +def assert_test_is_successful(runner, command): + result = runner.invoke(cli, split_command(command)) + assert result.exit_code == 0 diff --git a/tox.ini b/tox.ini index 83d505747..6da5b962e 100644 --- a/tox.ini +++ b/tox.ini @@ -45,7 +45,6 @@ deps = pytest == 4.6.11 pytest-mock == 2.0.0 pytest-cov == 2.10.0 - pexpect == 4.8.0 git+https://github.com/code42/py42.git@master#egg=py42 git+ssh://git@github.com/code42/c42eventextractor.git@master#egg=c42eventextractor From 516c2f11cd5070e9105f3e3dec62418fe9f1fcec Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 3 Feb 2021 20:45:09 +0530 Subject: [PATCH 185/349] handle custom exceptions in cases (#222) --- CHANGELOG.md | 10 ++++ setup.py | 2 +- src/code42cli/click_ext/groups.py | 8 +++ src/code42cli/cmds/cases.py | 6 ++ tests/cmds/conftest.py | 9 ++- tests/cmds/test_cases.py | 95 +++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c55f5fb6c..3118939fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Changed + +- The error text in cases command when: + - `cases create` sets a name that already exists in the system. + - `cases create` sets a description that has more than 250 characters. + - `cases update` sets a description that has more than 250 characters. + - `cases file-events add` is performed on an already closed case. + - `cases file-events add` sets an event id that is already added to the case. + - `cases file-events remove` is performed on an already closed case. + ### Added - New choice `TLS-TCP` for `--protocol` option used by `send-to` commands: diff --git a/setup.py b/setup.py index 68a782df4..bb8370549 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ "keyring==18.0.1", "keyrings.alt==3.2.0", "pandas>=1.1.3", - "py42>=1.11", + "py42>=1.11.1", ], extras_require={ "dev": [ diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index fcf2d3402..7745a0526 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -3,10 +3,14 @@ from collections import OrderedDict import click +from py42.exceptions import Py42CaseAlreadyHasEventError +from py42.exceptions import Py42CaseNameExistsError +from py42.exceptions import Py42DescriptionLimitExceededError from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42HTTPError from py42.exceptions import Py42InvalidRuleOperationError from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError +from py42.exceptions import Py42UpdateClosedCaseError from py42.exceptions import Py42UserAlreadyAddedError from py42.exceptions import Py42UserNotOnListError @@ -59,6 +63,10 @@ def invoke(self, ctx): Py42InvalidRuleOperationError, Py42LegalHoldNotFoundOrPermissionDeniedError, SyslogServerNetworkConnectionError, + Py42CaseNameExistsError, + Py42DescriptionLimitExceededError, + Py42CaseAlreadyHasEventError, + Py42UpdateClosedCaseError, ) as err: self.logger.log_error(err) raise Code42CLIError(str(err)) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index d232a9660..b8e8361e0 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -4,7 +4,9 @@ import click from py42.clients.cases import CaseStatus from py42.exceptions import Py42BadRequestError +from py42.exceptions import Py42CaseAlreadyHasEventError from py42.exceptions import Py42NotFoundError +from py42.exceptions import Py42UpdateClosedCaseError from code42cli.click_ext.groups import OrderedGroup from code42cli.errors import Code42CLIError @@ -241,6 +243,10 @@ def add(state, case_number, event_id): """Associate a file event to a case, by event ID.""" try: state.sdk.cases.file_events.add(case_number, event_id) + except Py42UpdateClosedCaseError: + raise + except Py42CaseAlreadyHasEventError: + raise except Py42BadRequestError: raise Code42CLIError("Invalid case-number or event-id.") diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index ea6397db4..c456e2a74 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -72,12 +72,17 @@ def cli_state_without_user(sdk_without_user, cli_state): @pytest.fixture -def user_already_added_error(mocker): +def custom_error(mocker): err = mocker.MagicMock(spec=HTTPError) resp = mocker.MagicMock(spec=Response) resp.text = "TEST_ERR" err.response = resp - return Py42UserAlreadyAddedError(err, TEST_ID, "detection list") + return err + + +@pytest.fixture +def user_already_added_error(custom_error): + return Py42UserAlreadyAddedError(custom_error, TEST_ID, "detection list") def get_filter_value_from_json(json, filter_index): diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index 4dc19ec1c..3816f8829 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -5,7 +5,11 @@ import pytest from py42.exceptions import Py42BadRequestError +from py42.exceptions import Py42CaseAlreadyHasEventError +from py42.exceptions import Py42CaseNameExistsError +from py42.exceptions import Py42DescriptionLimitExceededError from py42.exceptions import Py42NotFoundError +from py42.exceptions import Py42UpdateClosedCaseError from py42.response import Py42Response from code42cli.main import cli @@ -51,6 +55,26 @@ def py42_response(mocker): return mocker.MagicMock(spec=Py42Response) +@pytest.fixture +def case_already_exists_error(custom_error): + return Py42CaseNameExistsError(custom_error, "test case") + + +@pytest.fixture +def case_description_limit_exceeded_error(custom_error): + return Py42DescriptionLimitExceededError(custom_error) + + +@pytest.fixture +def case_already_has_event_error(custom_error): + return Py42CaseAlreadyHasEventError(custom_error) + + +@pytest.fixture +def update_on_a_closed_case_error(custom_error): + return Py42UpdateClosedCaseError(custom_error) + + def test_create_calls_create_with_expected_params(runner, cli_state): runner.invoke( cli, ["cases", "create", "TEST_CASE"], obj=cli_state, @@ -333,3 +357,74 @@ def test_file_events_list_when_missing_case_number_prints_error(runner, cli_stat result = runner.invoke(cli, command, obj=cli_state) assert result.exit_code == 2 assert MISSING_CASE_NUMBER_ARG in result.output + + +def test_cases_create_when_case_name_already_exists_raises_exception_prints_error_message( + runner, cli_state, case_already_exists_error +): + cli_state.sdk.cases.create.side_effect = case_already_exists_error + result = runner.invoke(cli, ["cases", "create", "test case"], obj=cli_state,) + assert ( + "Case name 'test case' already exists, please set another name" in result.output + ) + + +def test_cases_create_when_description_length_limit_exceeds_raises_exception_prints_error_message( + runner, cli_state, case_description_limit_exceeded_error +): + cli_state.sdk.cases.create.side_effect = case_description_limit_exceeded_error + result = runner.invoke( + cli, + ["cases", "create", "test case", "--description", "too long"], + obj=cli_state, + ) + assert "Description limit exceeded, max 250 characters allowed." in result.output + + +def test_cases_udpate_when_description_length_limit_exceeds_raises_exception_prints_error_message( + runner, cli_state, case_description_limit_exceeded_error +): + cli_state.sdk.cases.update.side_effect = case_description_limit_exceeded_error + result = runner.invoke( + cli, ["cases", "update", "1", "--description", "too long"], obj=cli_state, + ) + assert "Description limit exceeded, max 250 characters allowed." in result.output + + +def test_fileevents_add_on_closed_case_when_py42_raises_exception_prints_error_message( + runner, cli_state, update_on_a_closed_case_error +): + cli_state.sdk.cases.file_events.add.side_effect = update_on_a_closed_case_error + result = runner.invoke( + cli, + ["cases", "file-events", "add", "--case-number", "1", "--event-id", "1"], + obj=cli_state, + ) + cli_state.sdk.cases.file_events.add.assert_called_once_with(1, "1") + assert "Cannot update a closed case." in result.output + + +def test_fileevents_remove_on_closed_case_when_py42_raises_exception_prints_error_message( + runner, cli_state, update_on_a_closed_case_error +): + cli_state.sdk.cases.file_events.delete.side_effect = update_on_a_closed_case_error + result = runner.invoke( + cli, + ["cases", "file-events", "remove", "--case-number", "1", "--event-id", "1"], + obj=cli_state, + ) + cli_state.sdk.cases.file_events.delete.assert_called_once_with(1, "1") + assert "Cannot update a closed case." in result.output + + +def test_fileevents_when_event_id_is_already_associated_with_case_py42_raises_exception_prints_error_message( + runner, cli_state, case_already_has_event_error +): + cli_state.sdk.cases.file_events.add.side_effect = case_already_has_event_error + result = runner.invoke( + cli, + ["cases", "file-events", "add", "--case-number", "1", "--event-id", "1"], + obj=cli_state, + ) + cli_state.sdk.cases.file_events.add.assert_called_once_with(1, "1") + assert "Event is already associated to the case." in result.output From 8548e016ba2756b31d64af0fdee06d3dbdf41e19 Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Wed, 3 Feb 2021 20:57:41 +0530 Subject: [PATCH 186/349] Raise exception instead of echo in alert-rules add when rule-id is invalid (#223) * raise exception in alert-rules add instead of echo * added changelog * fixed changelog --- CHANGELOG.md | 4 ++++ src/code42cli/cmds/alert_rules.py | 2 +- tests/cmds/test_alert_rules.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3118939fb..755ed7649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `cases file-events add` sets an event id that is already added to the case. - `cases file-events remove` is performed on an already closed case. +### Fixed + +- Issue where `code42 alert-rules bulk add` would show as successful when adding users to a non-existent alert rule. + ### Added - New choice `TLS-TCP` for `--protocol` option used by `send-to` commands: diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index 101a5eb2d..fc10fa460 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -183,7 +183,7 @@ def _handle_rules_results(rules, rule_id=None): if not rules: id_msg = "with RuleId {} ".format(rule_id) if rule_id else "" msg = "No alert rules {}found.".format(id_msg) - echo(msg) + raise Code42CLIError(msg) return rules diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index 0a3e4f50c..b5e96b8b5 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -169,7 +169,7 @@ def test_add_user_when_rule_not_found_prints_expected_output(mocker, runner, cli ["alert-rules", "add-user", "--rule-id", TEST_RULE_ID, "-u", TEST_USERNAME], obj=cli_state, ) - assert "No alert rules with RuleId rule-id found." in result.output + assert "Error: No alert rules with RuleId rule-id found." in result.output def test_remove_user_removes_user_list_from_alert_rules(runner, cli_state): From d82f998ebfeab6430a258d09c3b3a9557c1e2b0f Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 4 Feb 2021 09:56:51 -0600 Subject: [PATCH 187/349] Disallow TLS specific args for non-TLS (#225) --- src/code42cli/cmds/search/__init__.py | 18 +++++++++++ src/code42cli/cmds/search/options.py | 1 + tests/cmds/test_alerts.py | 46 +++++++++++++++++++++++++++ tests/cmds/test_auditlogs.py | 46 +++++++++++++++++++++++++++ tests/cmds/test_securitydata.py | 46 +++++++++++++++++++++++++++ 5 files changed, 157 insertions(+) diff --git a/src/code42cli/cmds/search/__init__.py b/src/code42cli/cmds/search/__init__.py index 00b540d94..b6cef2b19 100644 --- a/src/code42cli/cmds/search/__init__.py +++ b/src/code42cli/cmds/search/__init__.py @@ -2,6 +2,7 @@ from code42cli.errors import Code42CLIError from code42cli.logger import get_logger_for_server +from code42cli.logger.enums import ServerProtocol from code42cli.output_formats import OutputFormat @@ -21,6 +22,8 @@ def invoke(self, ctx): protocol = ctx.params.get("protocol") output_format = ctx.params.get("format", OutputFormat.RAW) ignore_cert_validation = ctx.params.get("ignore_cert_validation") + _handle_incompatible_args(protocol, ignore_cert_validation, certs) + if ignore_cert_validation: certs = "ignore" @@ -28,3 +31,18 @@ def invoke(self, ctx): hostname, protocol, output_format, certs ) return super().invoke(ctx) + + +def _handle_incompatible_args(protocol, ignore_cert_validation, certs): + if protocol == ServerProtocol.TLS_TCP: + return + + arg = None + if ignore_cert_validation is not None: + arg = "--ignore-cert-validation" + elif certs is not None: + arg = "--certs" + if arg is not None: + raise click.BadOptionUsage( + arg, f"'{arg}' can only be used with '--protocol {ServerProtocol.TLS_TCP}'." + ) diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index 20c93a117..0d5ea7421 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -162,6 +162,7 @@ def server_options(f): help="Set to skip CA certificate validation. " "Incompatible with the 'certs' option.", is_flag=True, + default=None, cls=incompatible_with(["certs"]), ) f = hostname_arg(f) diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 67bdaf9ad..d1f99ea90 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -789,6 +789,52 @@ def test_send_to_when_given_ignore_cert_validation_uses_certs_equal_to_ignore_st ) +@pytest.mark.parametrize("protocol", (ServerProtocol.UDP, ServerProtocol.TCP)) +def test_send_to_when_given_ignore_cert_validation_with_non_tls_protocol_fails_expectedly( + cli_state, runner, protocol +): + res = runner.invoke( + cli, + [ + "alerts", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + protocol, + "--ignore-cert-validation", + ], + obj=cli_state, + ) + assert ( + "'--ignore-cert-validation' can only be used with '--protocol TLS-TCP'" + in res.output + ) + + +@pytest.mark.parametrize("protocol", (ServerProtocol.UDP, ServerProtocol.TCP)) +def test_send_to_when_given_certs_with_non_tls_protocol_fails_expectedly( + cli_state, runner, protocol +): + res = runner.invoke( + cli, + [ + "alerts", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + protocol, + "--certs", + "certs.pem", + ], + obj=cli_state, + ) + assert "'--certs' can only be used with '--protocol TLS-TCP'" in res.output + + def test_get_alert_details_batches_results_according_to_batch_size(sdk): extraction._ALERT_DETAIL_BATCH_SIZE = 2 sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index c29bfdc0e..0229111d0 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -311,6 +311,52 @@ def test_send_to_emits_events_in_chronological_order( ) +@pytest.mark.parametrize("protocol", (ServerProtocol.UDP, ServerProtocol.TCP)) +def test_send_to_when_given_ignore_cert_validation_with_non_tls_protocol_fails_expectedly( + cli_state, runner, protocol +): + res = runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + protocol, + "--ignore-cert-validation", + ], + obj=cli_state, + ) + assert ( + "'--ignore-cert-validation' can only be used with '--protocol TLS-TCP'" + in res.output + ) + + +@pytest.mark.parametrize("protocol", (ServerProtocol.UDP, ServerProtocol.TCP)) +def test_send_to_when_given_certs_with_non_tls_protocol_fails_expectedly( + cli_state, runner, protocol +): + res = runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + protocol, + "--certs", + "certs.pem", + ], + obj=cli_state, + ) + assert "'--certs' can only be used with '--protocol TLS-TCP'" in res.output + + @search_and_send_to_test def test_search_and_send_to_with_checkpoint_saves_expected_cursor_timestamp( cli_state, diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index d600b1af2..e01e5f15f 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -298,6 +298,52 @@ def test_send_to_with_saved_search_and_incompatible_argument_errors( assert "{} can't be used with: --saved-search".format(arg[0]) in result.output +@pytest.mark.parametrize("protocol", (ServerProtocol.UDP, ServerProtocol.TCP)) +def test_send_to_when_given_ignore_cert_validation_with_non_tls_protocol_fails_expectedly( + cli_state, runner, protocol +): + res = runner.invoke( + cli, + [ + "security-data", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + protocol, + "--ignore-cert-validation", + ], + obj=cli_state, + ) + assert ( + "'--ignore-cert-validation' can only be used with '--protocol TLS-TCP'" + in res.output + ) + + +@pytest.mark.parametrize("protocol", (ServerProtocol.UDP, ServerProtocol.TCP)) +def test_send_to_when_given_certs_with_non_tls_protocol_fails_expectedly( + cli_state, runner, protocol +): + res = runner.invoke( + cli, + [ + "security-data", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--protocol", + protocol, + "--certs", + "certs.pem", + ], + obj=cli_state, + ) + assert "'--certs' can only be used with '--protocol TLS-TCP'" in res.output + + @search_and_send_to_test def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( runner, cli_state, file_event_extractor, command From 2073a0aa2f039c5ef7db63ffc14487bc26e5eeb6 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Thu, 4 Feb 2021 22:17:47 -0600 Subject: [PATCH 188/349] Update auditlogs.py (#228) Small text updates for consistency and style. --- src/code42cli/cmds/auditlogs.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index 9488b0338..29e591ce7 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -49,20 +49,20 @@ def _get_audit_logs_default_header(): filter_option_user_ids = click.option( "--actor-user-id", required=False, - help="Filter results by actor user ids.", + help="Filter results by actor user IDs.", multiple=True, ) filter_option_user_ip_addresses = click.option( "--actor-ip", required=False, - help="Filter results by user ip addresses.", + help="Filter results by user IP addresses.", multiple=True, ) filter_option_affected_user_ids = click.option( "--affected-user-id", required=False, - help="Filter results by affected user ids.", + help="Filter results by affected user IDs.", multiple=True, ) filter_option_affected_usernames = click.option( @@ -94,7 +94,7 @@ def filter_options(f): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def audit_logs(state): - """Tools for getting audit-log data.""" + """Tools for getting audit log event data.""" # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_audit_log_cursor_store @@ -125,7 +125,7 @@ def search( format, use_checkpoint, ): - """Search audit logs.""" + """Search audit log events.""" formatter = OutputFormatter(format, _get_audit_logs_default_header()) cursor = _get_audit_log_cursor_store(state.profile.name) if use_checkpoint: @@ -179,7 +179,7 @@ def send_to( use_checkpoint, **kwargs, ): - """Send audit logs to the given server address in JSON format. + """Send audit log events to the given server address in JSON format. HOSTNAME format: address:port where port is optional and defaults to 514. """ From 5b51d41429ce93d870fa39e1ac2203f3afdec019 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Thu, 4 Feb 2021 22:18:26 -0600 Subject: [PATCH 189/349] Update departing_employee.py (#227) Small help text updates for consistency and style. --- src/code42cli/cmds/departing_employee.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 69c3ffcba..fd0bc4b37 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -38,7 +38,7 @@ def _get_filter_choices(): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def departing_employee(state): - """For adding and removing employees from the departing employees detection list.""" + """For adding and removing employees from the Departing Employees detection list.""" pass @@ -47,7 +47,7 @@ def departing_employee(state): @format_option @filter_option def _list(state, format, filter): - """Lists the employees on the Departing Employee list.""" + """Lists the users on the Departing Employees list.""" employee_generator = _get_departing_employees(state.sdk, filter) list_employees( employee_generator, format, {"departureDate": "Departure Date"}, @@ -65,7 +65,7 @@ def _list(state, format, filter): @notes_option @sdk_options() def add(state, username, cloud_alias, departure_date, notes): - """Add a user to the departing employees detection list.""" + """Add a user to the Departing Employees detection list.""" if departure_date: departure_date = departure_date.strftime(DATE_FORMAT) _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) @@ -75,7 +75,7 @@ def add(state, username, cloud_alias, departure_date, notes): @username_arg @sdk_options() def remove(state, username): - """Remove a user from the departing-employee detection list.""" + """Remove a user from the Departing Employees detection list.""" _remove_departing_employee(state.sdk, username) @@ -97,7 +97,7 @@ def bulk(state): @bulk.command( name="add", - help="Bulk add users to the departing employees detection list using a CSV file with " + help="Bulk add users to the Departing Employees detection list using a CSV file with " "format: {}.".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)), ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @@ -126,13 +126,13 @@ def handle_row(username, cloud_alias, departure_date, notes): run_bulk_process( handle_row, csv_rows, - progress_label="Adding users to departing employee detection list:", + progress_label="Adding users to the Departing Employees detection list:", ) @bulk.command( name="remove", - help="Bulk remove users from the departing employees detection list using a line-separated " + help="Bulk remove users from the Departing Employees detection list using a line-separated " "file of usernames.", ) @read_flat_file_arg @@ -146,7 +146,7 @@ def handle_row(username): run_bulk_process( handle_row, file_rows, - progress_label="Removing users from departing employee detection list:", + progress_label="Removing users from the Departing Employees detection list:", ) From 86833baf0812b19c727e8dd84b05815eb6d2f4be Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Fri, 5 Feb 2021 08:04:30 -0600 Subject: [PATCH 190/349] Update devices.py (#226) --- src/code42cli/cmds/devices.py | 10 +++++----- tests/cmds/test_devices.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 8b0a920aa..28885b74e 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -51,7 +51,7 @@ def change_device_name_option(help_msg): type=click.DateTime(formats=[DATE_FORMAT]), default=None, help="The date on which the archive should be purged from cold storage in yyyy-MM-dd format. " - "If not provided, the date will be set according to the appropriate org settings.", + "If not provided, the date will be set according to the appropriate organization settings.", ) @@ -125,7 +125,7 @@ def _verify_guid_type(device_guid): int(device_guid) return device_guid except ValueError: - raise Code42CLIError("Not a valid guid.") + raise Code42CLIError("Not a valid GUID.") def _update_cold_storage_purge_date(sdk, guid, purge_date): @@ -152,7 +152,7 @@ def _change_device_name(sdk, guid, name): @device_guid_argument @sdk_options() def show(state, device_guid, format=None): - """Print individual device info. Requires device GUID.""" + """Print individual device details. Requires device GUID.""" formatter = OutputFormatter(format, _device_info_keys_map()) backup_set_formatter = OutputFormatter(format, _backup_set_keys_map()) @@ -208,8 +208,8 @@ def _get_device_info(sdk, device_guid): required=False, type=str, default=None, - help="Limit devices to only the ones in the org you specify. " - "Note that child orgs will be included.", + help="Limit devices to only those in the organization you specify. " + "Note that child organizations will be included.", ) include_usernames_option = click.option( diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 1568893f3..b63799d5c 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -367,7 +367,7 @@ def test_deactivate_deactivates_device( def test_deactivate_when_given_non_guid_raises_before_making_request(runner, cli_state): result = runner.invoke(cli, ["devices", "deactivate", "not_a_guid"], obj=cli_state) assert result.exit_code == 1 - assert "Not a valid guid." in result.output + assert "Not a valid GUID." in result.output assert cli_state.sdk.devices.deactivate.call_count == 0 From 378fd0e1222ef825e00431da33b97d6e69cd6618 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 5 Feb 2021 14:52:27 -0600 Subject: [PATCH 191/349] handle windows log error from ssl connection (#231) --- src/code42cli/logger/handlers.py | 8 ++++++-- tests/logger/test_handlers.py | 29 ++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/code42cli/logger/handlers.py b/src/code42cli/logger/handlers.py index d8fecea50..d4cb6850e 100644 --- a/src/code42cli/logger/handlers.py +++ b/src/code42cli/logger/handlers.py @@ -11,7 +11,11 @@ class SyslogServerNetworkConnectionError(Exception): """An error raised when the connection is disrupted during logging.""" def __init__(self): - super().__init__("Network connection broken while sending results.") + super().__init__( + "The network connection broke while sending results. " + "This might happen if your connection requires TLS and you are attempting " + "unencrypted TCP communication." + ) class NoPrioritySysLogHandler(SysLogHandler): @@ -85,7 +89,7 @@ def handleError(self, record): them nowhere. """ t, _, _ = sys.exc_info() - if t == BrokenPipeError: + if issubclass(t, ConnectionError): raise SyslogServerNetworkConnectionError() super().handleError(record) diff --git a/tests/logger/test_handlers.py b/tests/logger/test_handlers.py index 436287fa5..bfc3ac445 100644 --- a/tests/logger/test_handlers.py +++ b/tests/logger/test_handlers.py @@ -49,10 +49,20 @@ def socket_mocks(mocker): @pytest.fixture() -def broken_pipe_error(mocker): - mock_exc_info = mocker.patch("code42cli.logger.handlers.sys.exc_info") - mock_exc_info.return_value = (BrokenPipeError, None, None) - return mock_exc_info +def system_exception_info(mocker): + return mocker.patch("code42cli.logger.handlers.sys.exc_info") + + +@pytest.fixture() +def broken_pipe_error(system_exception_info): + system_exception_info.return_value = (BrokenPipeError, None, None) + return system_exception_info + + +@pytest.fixture() +def connection_reset_error(system_exception_info): + system_exception_info.return_value = (ConnectionResetError, None, None) + return system_exception_info def _get_normal_socket_initializer_mocks(mocker, new_socket): @@ -204,7 +214,7 @@ def test_emit_when_udp_calls_socket_sendto_with_expected_message_and_address( expected_message, (_TEST_HOST, _TEST_PORT) ) - def test_handle_error_raises_expected_error( + def test_handle_error_when_broken_pipe_error_occurs_raises_expected_error( self, mock_file_event_log_record, broken_pipe_error ): handler = NoPrioritySysLogHandler( @@ -213,6 +223,15 @@ def test_handle_error_raises_expected_error( with pytest.raises(SyslogServerNetworkConnectionError): handler.handleError(mock_file_event_log_record) + def test_handle_error_when_connection_reset_error_occurs_raises_expected_error( + self, mock_file_event_log_record, connection_reset_error + ): + handler = NoPrioritySysLogHandler( + _TEST_HOST, _TEST_PORT, ServerProtocol.UDP, None + ) + with pytest.raises(SyslogServerNetworkConnectionError): + handler.handleError(mock_file_event_log_record) + def test_close_when_using_tls_unwraps_socket(self): handler = NoPrioritySysLogHandler( _TEST_HOST, _TEST_PORT, ServerProtocol.TLS_TCP, None From 9d75cc0c53ba504b899b20bee1cdbbf4c9291f5b Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Fri, 5 Feb 2021 15:14:05 -0600 Subject: [PATCH 192/349] fix state-dependent tests (#229) --- tests/cmds/test_cases.py | 92 ++++++++++++++++++++++++----------- tests/cmds/test_devices.py | 21 ++++---- tests/cmds/test_legal_hold.py | 4 +- 3 files changed, 76 insertions(+), 41 deletions(-) diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index 3816f8829..9157f68ef 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -18,23 +18,54 @@ ALL_EVENTS = """{ "events": [ { - "eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971", - "eventTimestamp": "2020-12-23T12:41:38.592Z" + "eventId": "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163", + "eventTimestamp": "2020-12-23T14:24:44.593Z", + "exposure": [ + "OutsideTrustedDomains", + "IsPublic" + ], + "fileName": "example.docx", + "filePath": "/Users/casey/Documents/" } - ] + ], + "totalCount": 42 }""" ALL_CASES = """{ "cases": [ { - "number": 3, - "name": "test@test.test", - "updatedAt": "2021-01-24T11:00:04.217878Z", - "subject": "942897" + "assignee": 273411254592236320, + "assigneeUsername": "test@example.com", + "createdAt": "2020-10-27T15:16:05.369203Z", + "createdByUserUid": 806150685834341100, + "createdByUsername": "adrian@example.com", + "lastModifiedByUserUid": 806150685834341100, + "lastModifiedByUsername": "adrian@example.com", + "name": "Sample case name", + "number": 942897, + "status": "OPEN", + "subject": 421380797518239200, + "subjectUsername": "casey@example.com", + "updatedAt": "2021-01-24T11:00:04.217878Z" } ], - "totalCount": 31 + "totalCount": 42 }""" -CASE_DETAILS = '{"number": 3, "name": "test@test.test"}' +CASE_DETAILS = """{ + "assignee": 273411254592236320, + "assigneeUsername": "test-single@example.com", + "createdAt": "2020-10-27T15:16:05.369203Z", + "createdByUserUid": 806150685834341100, + "createdByUsername": "adrian@example.com", + "lastModifiedByUserUid": 806150685834341100, + "lastModifiedByUsername": "adrian@example.com", + "name": "Sample case name", + "number": 123456, + "status": "OPEN", + "subject": 421380797518239200, + "subjectUsername": "casey@example.com", + "updatedAt": "2021-01-24T11:00:04.217878Z" +} +""" MISSING_ARGUMENT_ERROR = "Missing argument '{}'." MISSING_NAME = MISSING_ARGUMENT_ERROR.format("NAME") MISSING_CASE_NUMBER_ARG = MISSING_ARGUMENT_ERROR.format("CASE_NUMBER") @@ -43,13 +74,6 @@ MISSING_CASE_NUMBER_OPTION = MISSING_OPTION_ERROR.format("case-number") -@pytest.fixture -def error(mocker): - error = mocker.Mock(spec=Exception) - error.response = "error" - return error - - @pytest.fixture def py42_response(mocker): return mocker.MagicMock(spec=Py42Response) @@ -188,7 +212,6 @@ def gen(): cli_state.sdk.cases.get_all.return_value = gen() result = runner.invoke(cli, ["cases", "list"], obj=cli_state,) - assert "test@test.test" in result.output assert "2021-01-24T11:00:04.217878Z" in result.output assert "942897" in result.output @@ -206,11 +229,16 @@ def test_show_with_include_file_events_calls_file_events_get_all_with_expected_p runner.invoke( cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, ) + cli_state.sdk.cases.get.assert_called_once_with(1) cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) -def test_show_when_py42_raises_exception_prints_error_message(runner, cli_state, error): - cli_state.sdk.cases.file_events.get_all.side_effect = Py42NotFoundError(error) +def test_show_when_py42_raises_exception_prints_error_message( + runner, cli_state, custom_error +): + cli_state.sdk.cases.file_events.get_all.side_effect = Py42NotFoundError( + custom_error + ) result = runner.invoke( cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, ) @@ -222,21 +250,29 @@ def test_show_prints_expected_data(runner, cli_state, py42_response): py42_response.data = json.loads(CASE_DETAILS) cli_state.sdk.cases.get.return_value = py42_response result = runner.invoke(cli, ["cases", "show", "1"], obj=cli_state,) - assert "test@test.test" in result.output + assert "test-single@example.com" in result.output + assert "2021-01-24T11:00:04.217878Z" in result.output + assert "123456" in result.output def test_show_prints_expected_data_with_include_file_events_option( - runner, cli_state, py42_response + runner, cli_state, py42_response, mocker ): py42_response.text = ALL_EVENTS + get_case_response = mocker.MagicMock(spec=Py42Response) + get_case_response.data = json.loads(CASE_DETAILS) + cli_state.sdk.cases.get.return_value = get_case_response cli_state.sdk.cases.file_events.get_all.return_value = py42_response result = runner.invoke( cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, ) assert ( - "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971" + "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" in result.output ) + assert "test-single@example.com" in result.output + assert "2021-01-24T11:00:04.217878Z" in result.output + assert "123456" in result.output def test_show_case_when_missing_case_number_prints_error(runner, cli_state): @@ -273,9 +309,9 @@ def test_file_events_add_calls_add_event_with_expected_params(runner, cli_state) def test_file_events_add_when_py42_raises_exception_prints_error_message( - runner, cli_state, error + runner, cli_state, custom_error ): - cli_state.sdk.cases.file_events.add.side_effect = Py42BadRequestError(error) + cli_state.sdk.cases.file_events.add.side_effect = Py42BadRequestError(custom_error) result = runner.invoke( cli, ["cases", "file-events", "add", "--case-number", "1", "--event-id", "1"], @@ -309,9 +345,9 @@ def test_file_events_remove_calls_delete_event_with_expected_params(runner, cli_ def test_file_events_remove_when_py42_raises_exception_prints_error_message( - runner, cli_state, error + runner, cli_state, custom_error ): - cli_state.sdk.cases.file_events.delete.side_effect = Py42NotFoundError(error) + cli_state.sdk.cases.file_events.delete.side_effect = Py42NotFoundError(custom_error) result = runner.invoke( cli, ["cases", "file-events", "remove", "--case-number", "1", "--event-id", "1"], @@ -346,10 +382,10 @@ def test_file_events_list_prints_expected_data(runner, cli_state): cli_state.sdk.cases.file_events.get_all.return_value = json.loads(ALL_EVENTS) result = runner.invoke(cli, ["cases", "file-events", "list", "1"], obj=cli_state,) assert ( - "0_1d71796f-af5b-4231-9d8e-df6434da4663_984418168383179707_986472527798692818_971" + "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" in result.output ) - assert "2020-12-23T12:41:38.592Z" in result.output + assert "2020-12-23T14:24:44.593Z" in result.output def test_file_events_list_when_missing_case_number_prints_error(runner, cli_state): diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index b63799d5c..781eb2ca3 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -6,7 +6,6 @@ from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42NotFoundError from py42.response import Py42Response -from requests import HTTPError from requests import Response from code42cli import PRODUCT_NAME @@ -313,28 +312,28 @@ def reactivate_device_success(cli_state, empty_successful_response): @pytest.fixture -def deactivate_device_not_found_failure(cli_state): - cli_state.sdk.devices.deactivate.side_effect = Py42NotFoundError(HTTPError()) +def deactivate_device_not_found_failure(cli_state, custom_error): + cli_state.sdk.devices.deactivate.side_effect = Py42NotFoundError(custom_error) @pytest.fixture -def reactivate_device_not_found_failure(cli_state): - cli_state.sdk.devices.reactivate.side_effect = Py42NotFoundError(HTTPError()) +def reactivate_device_not_found_failure(cli_state, custom_error): + cli_state.sdk.devices.reactivate.side_effect = Py42NotFoundError(custom_error) @pytest.fixture -def deactivate_device_in_legal_hold_failure(cli_state): - cli_state.sdk.devices.deactivate.side_effect = Py42BadRequestError(HTTPError()) +def deactivate_device_in_legal_hold_failure(cli_state, custom_error): + cli_state.sdk.devices.deactivate.side_effect = Py42BadRequestError(custom_error) @pytest.fixture -def deactivate_device_not_allowed_failure(cli_state): - cli_state.sdk.devices.deactivate.side_effect = Py42ForbiddenError(HTTPError()) +def deactivate_device_not_allowed_failure(cli_state, custom_error): + cli_state.sdk.devices.deactivate.side_effect = Py42ForbiddenError(custom_error) @pytest.fixture -def reactivate_device_not_allowed_failure(cli_state): - cli_state.sdk.devices.reactivate.side_effect = Py42ForbiddenError(HTTPError()) +def reactivate_device_not_allowed_failure(cli_state, custom_error): + cli_state.sdk.devices.reactivate.side_effect = Py42ForbiddenError(custom_error) @pytest.fixture diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index da218be74..4c1124f56 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -240,9 +240,9 @@ def check_matter_accessible_success(cli_state, matter_response): @pytest.fixture -def check_matter_accessible_failure(cli_state): +def check_matter_accessible_failure(cli_state, custom_error): cli_state.sdk.legalhold.get_matter_by_uid.side_effect = Py42BadRequestError( - HTTPError() + custom_error ) From fe7ac4881c097e4040ab05bd2176b0bb0b607a65 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 8 Feb 2021 10:18:33 -0600 Subject: [PATCH 193/349] prefix error (#232) --- src/code42cli/cmds/search/extraction.py | 7 ++++++- tests/cmds/test_alerts.py | 17 +++++++++++++++++ tests/cmds/test_auditlogs.py | 3 --- tests/cmds/test_securitydata.py | 7 ++----- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index 647e39fe7..c55983b66 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -58,7 +58,12 @@ def handle_error(exception): else: message = exception logger.log_error(message) - secho(str(message), err=True, fg="red") + + message = str(message) + if not message.lower().startswith("error:"): + message = f"Error: {message}" + + secho(message, err=True, fg="red") handlers.handle_error = handle_error if cursor_store: diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index d1f99ea90..6946dae5d 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -1,4 +1,5 @@ import json +import logging import py42.sdk.queries.alerts.filters as f import pytest @@ -8,6 +9,7 @@ from tests.cmds.conftest import get_mark_for_search_and_send_to from tests.conftest import get_test_date_str +from code42cli import errors from code42cli import PRODUCT_NAME from code42cli.cmds.search import extraction from code42cli.cmds.search.cursor_store import AlertCursorStore @@ -703,6 +705,21 @@ def test_search_and_send_to_with_or_query_flag_produces_expected_query( assert actual_query == expected_query +@search_and_send_to_test +def test_search_and_send_to_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( + runner, cli_state, caplog, command +): + errors.ERRORED = False + exception_msg = "Test Exception" + cli_state.sdk.alerts.search.side_effect = Exception(exception_msg) + with caplog.at_level(logging.ERROR): + result = runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) + assert "Error:" in result.output + assert exception_msg in result.output + assert exception_msg in caplog.text + assert errors.ERRORED + + @pytest.mark.parametrize( "protocol", (ServerProtocol.TLS_TCP, ServerProtocol.TLS_TCP, ServerProtocol.UDP) ) diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index 0229111d0..7b0e3cca3 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -61,9 +61,6 @@ "timestamp": TEST_AUDIT_LOG_TIMESTAMP_3, }, ] -TEST_CHECKPOINT_EVENT_HASHLIST = [ - hash_event(event) for event in TEST_EVENTS_WITH_SAME_TIMESTAMP -] search_and_send_to_test = get_mark_for_search_and_send_to("audit-logs") diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index e01e5f15f..bbe5d894e 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -773,13 +773,10 @@ def test_search_and_send_to_when_extraction_handles_error_expected_message_logge ): errors.ERRORED = False exception_msg = "Test Exception" - - def file_search_error(x): - raise Exception(exception_msg) - - cli_state.sdk.securitydata.search_file_events.side_effect = file_search_error + cli_state.sdk.securitydata.search_file_events.side_effect = Exception(exception_msg) with caplog.at_level(logging.ERROR): result = runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) + assert "Error:" in result.output assert exception_msg in result.output assert exception_msg in caplog.text assert errors.ERRORED From 188aba602d26d3b8a503f65a8e2b1e632ce73605 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:42:12 -0600 Subject: [PATCH 194/349] Update alerts.py (#233) --- src/code42cli/cmds/alerts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index dd6e545a8..9b2bc776d 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -175,7 +175,7 @@ def alert_options(f): @click.group(cls=code42cli.click_ext.groups.OrderedGroup) @opt.sdk_options(hidden=True) def alerts(state): - """Tools for getting alert data.""" + """Get and send alert data.""" # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_alert_cursor_store From c5bf37639385764f870b5ae7966e520e172218d7 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:42:35 -0600 Subject: [PATCH 195/349] Update auditlogs.py (#234) --- src/code42cli/cmds/auditlogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index 29e591ce7..ebd66ede8 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -94,7 +94,7 @@ def filter_options(f): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def audit_logs(state): - """Tools for getting audit log event data.""" + """Get and send audit log event data.""" # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_audit_log_cursor_store From 74fe45f4d3078269475911392f3893801c70e310 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:42:54 -0600 Subject: [PATCH 196/349] Update cases.py (#235) --- src/code42cli/cmds/cases.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index b8e8361e0..aaf5d2e59 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -68,7 +68,7 @@ def _get_events_header(): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def cases(state): - """For managing cases and events associated with cases.""" + """Manage cases and events associated with cases.""" pass From dedbc0d16b7751da3788d39a43ee4c3d1d19c454 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:43:17 -0600 Subject: [PATCH 197/349] Create departing_employee.py (#236) --- src/code42cli/cmds/departing_employee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index fd0bc4b37..902b2bd0c 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -38,7 +38,7 @@ def _get_filter_choices(): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def departing_employee(state): - """For adding and removing employees from the Departing Employees detection list.""" + """Add and remove employees from the Departing Employees detection list.""" pass From 0651a1014273c144428696753f8d34171b531ecc Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:43:34 -0600 Subject: [PATCH 198/349] Update devices.py (#237) --- src/code42cli/cmds/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 28885b74e..426ddb019 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -25,7 +25,7 @@ @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def devices(state): - """For managing devices within your Code42 environment.""" + """Manage devices within your Code42 environment.""" pass From 6295519abde6a7ed5ea4a41f2328b85d92fa1ab6 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:44:19 -0600 Subject: [PATCH 199/349] Update high_risk_employee.py (#238) --- src/code42cli/cmds/high_risk_employee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 5d87eabac..987ea5707 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -49,7 +49,7 @@ def _get_filter_choices(): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def high_risk_employee(state): - """For adding and removing employees from the high risk employees detection list.""" + """Add and remove employees from the High Risk Employees detection list.""" pass From 1a8e6fe6510fc6de58fc8f3eb661948a24821bd3 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:44:39 -0600 Subject: [PATCH 200/349] Update legal_hold.py (#239) --- src/code42cli/cmds/legal_hold.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 76c4bbc76..ece6bbe34 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -30,7 +30,7 @@ @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def legal_hold(state): - """For adding and removing custodians from legal hold matters.""" + """Add and remove custodians from legal hold matters.""" pass From 3661ebb767a82a8800ff6d30d12df581dc272095 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:44:59 -0600 Subject: [PATCH 201/349] Update profile.py (#240) --- src/code42cli/cmds/profile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 03351b86e..206c5e161 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -14,7 +14,7 @@ @click.group() def profile(): - """For managing Code42 connection settings.""" + """Manage Code42 connection settings.""" pass From 90f08ea1db6ef00b67b19d1ae726c1ab273fb141 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:45:14 -0600 Subject: [PATCH 202/349] Update securitydata.py (#241) --- src/code42cli/cmds/securitydata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index ac22512ad..b9a6188d0 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -198,7 +198,7 @@ def file_event_options(f): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def security_data(state): - """Tools for getting file event data.""" + """Get and send file event data.""" # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_file_event_cursor_store From 7db23b488a031c520d4ddb92da19e6fa40bb5f9a Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 8 Feb 2021 12:45:32 -0600 Subject: [PATCH 203/349] add missing tests (#242) --- tests/integration/test_alerts.py | 1 + tests/integration/test_auditlogs.py | 1 + tests/integration/test_departing_employee.py | 11 +++++++++++ tests/integration/test_high_risk_employee.py | 11 +++++++++++ tests/integration/test_legal_hold.py | 1 + 5 files changed, 25 insertions(+) create mode 100644 tests/integration/test_departing_employee.py create mode 100644 tests/integration/test_high_risk_employee.py diff --git a/tests/integration/test_alerts.py b/tests/integration/test_alerts.py index d790126b1..27a723fd8 100644 --- a/tests/integration/test_alerts.py +++ b/tests/integration/test_alerts.py @@ -38,6 +38,7 @@ def test_alerts_send_to_returns_success_return_code( assert result.exit_code == 0 +@pytest.mark.integration def test_alerts_advanced_query_returns_success_return_code( runner, integration_test_profile ): diff --git a/tests/integration/test_auditlogs.py b/tests/integration/test_auditlogs.py index 3ad3e4be4..7fd000d5e 100644 --- a/tests/integration/test_auditlogs.py +++ b/tests/integration/test_auditlogs.py @@ -39,6 +39,7 @@ def test_auditlogs_search_command_with_short_hand_begin_returns_success_return_c assert_test_is_successful(runner, append_profile(command)) +@pytest.mark.integration def test_auditlogs_search_command_with_full_begin_returns_success_return_code( runner, integration_test_profile, ): diff --git a/tests/integration/test_departing_employee.py b/tests/integration/test_departing_employee.py new file mode 100644 index 000000000..f3dca5be5 --- /dev/null +++ b/tests/integration/test_departing_employee.py @@ -0,0 +1,11 @@ +import pytest +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful + + +@pytest.mark.integration +def test_departing_employee_list_command_returns_success_return_code( + runner, integration_test_profile +): + command = "departing-employee list" + assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_high_risk_employee.py b/tests/integration/test_high_risk_employee.py new file mode 100644 index 000000000..26aeb550a --- /dev/null +++ b/tests/integration/test_high_risk_employee.py @@ -0,0 +1,11 @@ +import pytest +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful + + +@pytest.mark.integration +def test_high_risk_employee_list_command_returns_success_return_code( + runner, integration_test_profile +): + command = "high-risk-employee list" + assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_legal_hold.py b/tests/integration/test_legal_hold.py index 0b4603f60..faa499efb 100644 --- a/tests/integration/test_legal_hold.py +++ b/tests/integration/test_legal_hold.py @@ -11,6 +11,7 @@ def test_legal_hold_list_command_returns_success_return_code( assert_test_is_successful(runner, append_profile(command)) +@pytest.mark.integration def test_legal_hold_show_command_returns_success_return_code( runner, integration_test_profile ): From 71aadbfb998684cfd8a92f8ee758c09b60eb5b91 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 11 Feb 2021 11:04:53 -0600 Subject: [PATCH 204/349] release prep (#243) --- CHANGELOG.md | 26 +++++++++++++------------- src/code42cli/__version__.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 755ed7649..ef86acdf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased - -### Changed - -- The error text in cases command when: - - `cases create` sets a name that already exists in the system. - - `cases create` sets a description that has more than 250 characters. - - `cases update` sets a description that has more than 250 characters. - - `cases file-events add` is performed on an already closed case. - - `cases file-events add` sets an event id that is already added to the case. - - `cases file-events remove` is performed on an already closed case. +## 1.3.0 - 2021-02-11 ### Fixed @@ -30,9 +20,19 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 security-data send-to` - `code42 alerts send-to` - `code42 audit-logs send-to` - for more securely transporting data. + for more securely transporting data. Included are new flags: + - `--certs` + - `--ignore-cert-validation` + +### Changed -- `--certs` option for `send-to` commands when using `--protocol TLS-TCP`. +- The error text in cases command when: + - `cases create` sets a name that already exists in the system. + - `cases create` sets a description that has more than 250 characters. + - `cases update` sets a description that has more than 250 characters. + - `cases file-events add` is performed on an already closed case. + - `cases file-events add` sets an event id that is already added to the case. + - `cases file-events remove` is performed on an already closed case. ## 1.2.0 - 2021-01-25 diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index c68196d1c..67bc602ab 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.3.0" From 3fdc7cea32b10a454af5ff65d73992aeb48f9b87 Mon Sep 17 00:00:00 2001 From: Peter Briggs Date: Wed, 17 Feb 2021 09:48:43 -0600 Subject: [PATCH 205/349] Nightly build posts results to slack app (#244) * Nightly build posts results to slack app --- .github/workflows/nightly.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index cedec8aa6..7208ee10b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -33,3 +33,11 @@ jobs: env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock run: tox -e nightly # Run tox using latest master branch from py42/c42eventextractor + - name: Notify Slack Action + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + fields: repo,message,commit,author,action,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: failure() From d71a2f5bd7107c0344d92110aeafd579e15c923e Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 18 Feb 2021 10:26:50 -0600 Subject: [PATCH 206/349] Chore/dataframe formatter handle nulls (#245) * refactor DataFrameOutputter for clarity and convert nulls to empty string in table/csv outputs * change Exception to ValueError * update test with correct exception * name test correctly --- src/code42cli/output_formats.py | 75 +++++++++++++----------- tests/test_output_formats.py | 100 ++++++++++++++++++-------------- 2 files changed, 97 insertions(+), 78 deletions(-) diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index b327f362c..d5faba156 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -3,7 +3,6 @@ import json import click -from pandas import DataFrame from code42cli.logger.formatters import CEF_TEMPLATE from code42cli.logger.formatters import map_event_to_cef @@ -81,41 +80,51 @@ def _requires_list_output(self): class DataFrameOutputFormatter: def __init__(self, output_format): - output_format = output_format.upper() if output_format else OutputFormat.TABLE - self.output_format = output_format - self._format_func = DataFrame.to_string - self._output_args = {"index": False} + self.output_format = ( + output_format.upper() if output_format else OutputFormat.TABLE + ) - if output_format == OutputFormat.CSV: - self._format_func = DataFrame.to_csv - elif output_format == OutputFormat.RAW: - self._format_func = DataFrame.to_json - self._output_args.update( - { - "orient": "records", - "lines": False, - "index": True, - "default_handler": str, - } - ) - elif output_format == OutputFormat.JSON: - self._format_func = DataFrame.to_json - self._output_args.update( - { - "orient": "records", - "lines": True, - "index": True, - "default_handler": str, - } - ) + def get_formatted_output(self, df, **kwargs): + if self.output_format == OutputFormat.JSON: + defaults = { + "orient": "records", + "lines": True, + "index": True, + "default_handler": str, + } + defaults.update(kwargs) + return df.to_json(**defaults) + + elif self.output_format == OutputFormat.RAW: + defaults = { + "orient": "records", + "lines": False, + "index": True, + "default_handler": str, + } + defaults.update(kwargs) + return df.to_json(**defaults) + + elif self.output_format == OutputFormat.CSV: + defaults = {"index": False} + defaults.update(kwargs) + df = df.fillna("") + return df.to_csv(**defaults) + + elif self.output_format == OutputFormat.TABLE: + defaults = {"index": False} + defaults.update(kwargs) + df = df.fillna("") + return df.to_string(**defaults) - def _format_output(self, output, *args, **kwargs): - self._output_args.update(kwargs) - return self._format_func(output, *args, **self._output_args) + else: + raise ValueError( + f"DataFrameOutputFormatter received an invalid format: {self.output_format}" + ) - def echo_formatted_dataframe(self, output, *args, **kwargs): - str_output = self._format_output(output, *args, **kwargs) - if len(output) <= 10: + def echo_formatted_dataframe(self, df, **kwargs): + str_output = self.get_formatted_output(df, **kwargs) + if len(df) <= 10: click.echo(str_output) else: click.echo_via_pager(str_output) diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index b735955ea..9be0e4bc6 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -2,15 +2,17 @@ from collections import OrderedDict import pytest +from numpy import NaN from pandas import DataFrame import code42cli.output_formats as output_formats_module from code42cli.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP +from code42cli.output_formats import DataFrameOutputFormatter from code42cli.output_formats import FileEventsOutputFormat from code42cli.output_formats import FileEventsOutputFormatter +from code42cli.output_formats import OutputFormat from code42cli.output_formats import to_cef - TEST_DATA = [ { "type$": "RULE_METADATA", @@ -771,53 +773,61 @@ def test_security_data_output_format_has_expected_options(): class TestDataFrameOutputFormatter: - def test_init_sets_format_func_to_formatted_json_function_when_json_format_option_is_passed( - self, mock_dataframe_to_json - ): - output_format = output_formats_module.OutputFormat.RAW - formatter = output_formats_module.DataFrameOutputFormatter(output_format) - formatter.echo_formatted_dataframe(TEST_DATAFRAME) - mock_dataframe_to_json.assert_called_once_with( - TEST_DATAFRAME, - orient="records", - lines=False, - index=True, - default_handler=str, + test_df = DataFrame( + [ + {"string_column": "string1", "int_column": 42, "null_column": None}, + {"string_column": "string2", "int_column": 43, "null_column": NaN}, + ] + ) + + def test_format_when_none_passed_defaults_to_table(self): + formatter = DataFrameOutputFormatter(output_format=None) + assert formatter.output_format == OutputFormat.TABLE + + def test_format_when_unknown_format_raises_value_error(self): + with pytest.raises(ValueError): + formatter = DataFrameOutputFormatter("NOT_A_FORMAT") + formatter.get_formatted_output(self.test_df) + + def test_json_formatter_converts_to_expected_string(self): + formatter = DataFrameOutputFormatter(OutputFormat.JSON) + output = formatter.get_formatted_output(self.test_df) + assert ( + output + == '{"string_column":"string1","int_column":42,"null_column":null}\n{"string_column":"string2","int_column":43,"null_column":null}' ) - def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_passed( - self, mock_dataframe_to_json - ): - output_format = output_formats_module.OutputFormat.JSON - formatter = output_formats_module.DataFrameOutputFormatter(output_format) - formatter.echo_formatted_dataframe(TEST_DATAFRAME) - mock_dataframe_to_json.assert_called_once_with( - TEST_DATAFRAME, - orient="records", - lines=True, - index=True, - default_handler=str, + def test_raw_formatter_converts_to_expected_string(self): + formatter = DataFrameOutputFormatter(OutputFormat.RAW) + output = formatter.get_formatted_output(self.test_df) + assert ( + output + == '[{"string_column":"string1","int_column":42,"null_column":null},{"string_column":"string2","int_column":43,"null_column":null}]' ) - def test_init_sets_format_func_to_table_function_when_table_format_option_is_passed( - self, mock_dataframe_to_string - ): - output_format = output_formats_module.OutputFormat.TABLE - formatter = output_formats_module.DataFrameOutputFormatter(output_format) - formatter.echo_formatted_dataframe(TEST_DATAFRAME) - mock_dataframe_to_string.assert_called_once_with(TEST_DATAFRAME, index=False) + def test_csv_formatter_converts_to_expected_string(self): + formatter = DataFrameOutputFormatter(OutputFormat.CSV) + output = formatter.get_formatted_output(self.test_df) + assert ( + output == "string_column,int_column,null_column\nstring1,42,\nstring2,43,\n" + ) - def test_init_sets_format_func_to_csv_function_when_csv_format_option_is_passed( - self, mock_dataframe_to_csv - ): - output_format = output_formats_module.OutputFormat.CSV - formatter = output_formats_module.DataFrameOutputFormatter(output_format) - formatter.echo_formatted_dataframe(TEST_DATAFRAME) - mock_dataframe_to_csv.assert_called_once_with(TEST_DATAFRAME, index=False) + def test_table_formatter_converts_to_expected_string(self): + formatter = DataFrameOutputFormatter(OutputFormat.TABLE) + output = formatter.get_formatted_output(self.test_df) + assert output == ( + "string_column int_column null_column\n" + " string1 42 \n" + " string2 43 " + ) - def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed( - self, mock_dataframe_to_string - ): - formatter = output_formats_module.DataFrameOutputFormatter(None) - formatter.echo_formatted_dataframe(TEST_DATAFRAME) - mock_dataframe_to_string.assert_called_once_with(TEST_DATAFRAME, index=False) + def test_echo_formatted_dataframe_uses_pager_when_gt_10_rows(self, mocker): + mock_echo = mocker.patch("click.echo") + mock_pager = mocker.patch("click.echo_via_pager") + formatter = DataFrameOutputFormatter(OutputFormat.TABLE) + big_df = DataFrame([{"column": val} for val in range(11)]) + small_df = DataFrame([{"column": val} for val in range(5)]) + formatter.echo_formatted_dataframe(big_df) + formatter.echo_formatted_dataframe(small_df) + assert mock_echo.call_count == 1 + assert mock_pager.call_count == 1 From 53cf4b30b3e5bf0f4d9897a21b7e672c1f0e4f3e Mon Sep 17 00:00:00 2001 From: "C.D" <47841169+3nin6@users.noreply.github.com> Date: Thu, 18 Feb 2021 13:37:39 -0600 Subject: [PATCH 207/349] feature/205-profile-update (#246) --- CHANGELOG.md | 9 +++++++ src/code42cli/cmds/profile.py | 49 ++++++++++++++++++++++------------- src/code42cli/profile.py | 1 - tests/cmds/test_profile.py | 28 ++++++++++++++++++++ 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef86acdf6..2e83ec4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Changed + +- Command options for `profile update`: + - `-n` `--name` is not required, and if omitted with use default profile. + - `-s` `--server` and `-u` `--username` are not required and can be updated independently now. + - Example: `code42 profile update -s 1.2.3.4:1234` + ## 1.3.0 - 2021-02-11 ### Fixed diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 206c5e161..88a2bdfef 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -22,19 +22,32 @@ def profile_name_arg(required=False): return click.argument("profile_name", required=required) -name_option = click.option( - "-n", - "--name", - required=True, - help="The name of the Code42 CLI profile to use when executing this command.", -) -server_option = click.option( - "-s", "--server", required=True, help="The URL you use to sign into Code42.", -) +def name_option(required=False): + return click.option( + "-n", + "--name", + required=required, + help="The name of the Code42 CLI profile to use when executing this command.", + ) + + +def server_option(required=False): + return click.option( + "-s", + "--server", + required=required, + help="The URL you use to sign into Code42.", + ) + + +def username_option(required=False): + return click.option( + "-u", + "--username", + required=required, + help="The username of the Code42 API user.", + ) -username_option = click.option( - "-u", "--username", required=True, help="The username of the Code42 API user.", -) password_option = click.option( "--password", @@ -66,9 +79,9 @@ def show(profile_name): @profile.command() -@name_option -@server_option -@username_option +@name_option(required=True) +@server_option(required=True) +@username_option(required=True) @password_option @yes_option(hidden=True) @disable_ssl_option @@ -83,9 +96,9 @@ def create(name, server, username, password, disable_ssl_errors): @profile.command() -@name_option -@server_option -@username_option +@name_option() +@server_option() +@username_option() @password_option @disable_ssl_option def update(name, server, username, password, disable_ssl_errors): diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index 385492dfe..f7c7cf678 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -102,7 +102,6 @@ def switch_default_profile(profile_name): def create_profile(name, server, username, ignore_ssl_errors): if profile_exists(name): raise Code42CLIError("A profile named '{}' already exists.".format(name)) - config_accessor.create_profile(name, server, username, ignore_ssl_errors) diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 30733cc5b..99ddf6422 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -254,6 +254,34 @@ def test_update_profile_updates_existing_profile( ) +def test_update_profile_updates_default_profile( + runner, mock_cliprofile_namespace, user_agreement, valid_connection, profile +): + name = "foo" + profile.name = name + mock_cliprofile_namespace.get_profile.return_value = profile + runner.invoke( + cli, ["profile", "update", "-s", "bar", "-u", "baz", "--disable-ssl-errors"], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, "bar", "baz", True + ) + + +def test_update_profile_updates_name_alone( + runner, mock_cliprofile_namespace, user_agreement, valid_connection, profile +): + name = "foo" + profile.name = name + mock_cliprofile_namespace.get_profile.return_value = profile + runner.invoke( + cli, ["profile", "update", "-u", "baz", "--disable-ssl-errors"], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, None, "baz", True + ) + + def test_update_profile_if_user_does_not_agree_does_not_save_password( runner, mock_cliprofile_namespace, user_disagreement, invalid_connection, profile ): From ee0136de14976b72a19da219240abecdd689ca3e Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 22 Feb 2021 10:20:30 -0600 Subject: [PATCH 208/349] Fix CLI FileCategory choice breakage (#247) * work around file category camelcase conversion to allow backwards compat values * add camel-case Zip option * make logic/tests work on current py42 as well as updated * style --- src/code42cli/click_ext/types.py | 16 ++++++++++++ src/code42cli/cmds/securitydata.py | 21 ++++++++++++++- tests/cmds/test_securitydata.py | 41 +++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/code42cli/click_ext/types.py b/src/code42cli/click_ext/types.py index 607e9dc4e..1baf34b16 100644 --- a/src/code42cli/click_ext/types.py +++ b/src/code42cli/click_ext/types.py @@ -116,3 +116,19 @@ def _get_dt_from_date_time_pair(date, time): raise BadParameter("Unable to parse date string: {}.".format(date_string)) else: return dt + + +class MapChoice(click.Choice): + """Choice subclass that takes an extra map of additional 'valid' keys to map to correct + choices list, allowing backward compatible choice changes. The extra keys don't show up + in help text, but work when passed as a choice. + """ + + def __init__(self, choices, extras_map, **kwargs): + self.extras_map = extras_map + super().__init__(choices, **kwargs) + + def convert(self, value, param, ctx): + if value in self.extras_map: + value = self.extras_map[value] + return super().convert(value, param, ctx) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index b9a6188d0..33ebbc61f 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -13,6 +13,7 @@ import code42cli.options as opt from code42cli.click_ext.groups import OrderedGroup from code42cli.click_ext.options import incompatible_with +from code42cli.click_ext.types import MapChoice from code42cli.cmds.search import SendToCommand from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.cmds.search.extraction import handle_no_events @@ -97,7 +98,25 @@ file_category_option = click.option( "--file-category", multiple=True, - type=click.Choice(list(FileCategory.choices())), + type=MapChoice( + choices=list(FileCategory.choices()), + extras_map={ + "AUDIO": FileCategory.AUDIO, + "DOCUMENT": FileCategory.DOCUMENT, + "EXECUTABLE": FileCategory.EXECUTABLE, + "IMAGE": FileCategory.IMAGE, + "PDF": FileCategory.PDF, + "PRESENTATION": FileCategory.PRESENTATION, + "SCRIPT": FileCategory.SCRIPT, + "SOURCE_CODE": FileCategory.SOURCE_CODE, + "SPREADSHEET": FileCategory.SPREADSHEET, + "VIDEO": FileCategory.VIDEO, + "VIRTUAL_DISK_IMAGE": FileCategory.VIRTUAL_DISK_IMAGE, + "ARCHIVE": FileCategory.ZIP, + "ZIP": FileCategory.ZIP, + "Zip": FileCategory.ZIP, + }, + ), callback=searchopt.is_in_filter(f.FileCategory), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file can be classified by one of these categories.", diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index bbe5d894e..c08972c23 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -5,6 +5,7 @@ import pytest from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.file_event_query import FileEventQuery +from py42.sdk.queries.fileevents.filters.file_filter import FileCategory from tests.cmds.conftest import filter_term_is_in_call_args from tests.cmds.conftest import get_filter_value_from_json from tests.cmds.conftest import get_mark_for_search_and_send_to @@ -650,7 +651,7 @@ def test_search_and_send_to_when_given_file_path_uses_file_path_filter( def test_search_and_send_to_when_given_file_category_uses_file_category_filter( runner, cli_state, file_event_extractor, command ): - file_category = "IMAGE" + file_category = FileCategory.IMAGE command = [*command, "--begin", "1h", "--file-category", file_category] runner.invoke( cli, command, obj=cli_state, @@ -659,6 +660,44 @@ def test_search_and_send_to_when_given_file_category_uses_file_category_filter( assert str(f.FileCategory.is_in([file_category])) in filter_strings +@pytest.mark.parametrize( + "category_choice", + [ + ("AUDIO", FileCategory.AUDIO), + ("DOCUMENT", FileCategory.DOCUMENT), + ("EXECUTABLE", FileCategory.EXECUTABLE), + ("IMAGE", FileCategory.IMAGE), + ("PDF", FileCategory.PDF), + ("PRESENTATION", FileCategory.PRESENTATION), + ("SCRIPT", FileCategory.SCRIPT), + ("SOURCE_CODE", FileCategory.SOURCE_CODE), + ("SPREADSHEET", FileCategory.SPREADSHEET), + ("VIDEO", FileCategory.VIDEO), + ("VIRTUAL_DISK_IMAGE", FileCategory.VIRTUAL_DISK_IMAGE), + ("ARCHIVE", FileCategory.ZIP), + ("ZIP", FileCategory.ZIP), + ("Zip", FileCategory.ZIP), + ], +) +def test_all_caps_file_category_choices_convert_to_filecategory_constant( + runner, cli_state, file_event_extractor, category_choice +): + ALL_CAPS_VALUE, camelCaseValue = category_choice + command = [ + "security-data", + "search", + "--begin", + "1h", + "--file-category", + ALL_CAPS_VALUE, + ] + runner.invoke( + cli, command, obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(f.FileCategory.is_in([camelCaseValue])) in filter_strings + + @search_and_send_to_test def test_search_and_send_to_when_given_process_owner_uses_process_owner_filter( runner, cli_state, file_event_extractor, command From 7268e4808c48bcc2ec74c558014191cbe9a0b7a5 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 22 Feb 2021 16:40:59 -0600 Subject: [PATCH 209/349] Don't prompt to set password on `profile update` when it's already set (#248) * don't prompt to set password when it's already set * fix tests --- src/code42cli/cmds/profile.py | 2 +- tests/cmds/test_profile.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 88a2bdfef..53204155c 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -107,7 +107,7 @@ def update(name, server, username, password, disable_ssl_errors): cliprofile.update_profile(c42profile.name, server, username, disable_ssl_errors) if password: _set_pw(name, password) - else: + elif not c42profile.has_stored_password: _prompt_for_allow_password_set(c42profile.name) echo("Profile '{}' has been updated.".format(c42profile.name)) diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 99ddf6422..b01b7a7e3 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -310,6 +310,7 @@ def test_update_profile_if_credentials_invalid_password_not_saved( ): name = "foo" profile.name = name + profile.has_stored_password = False mock_cliprofile_namespace.get_profile.return_value = profile result = runner.invoke( @@ -335,6 +336,7 @@ def test_update_profile_if_user_agrees_and_valid_connection_sets_password( ): name = "foo" profile.name = name + profile.has_stored_password = False mock_cliprofile_namespace.get_profile.return_value = profile runner.invoke( cli, From f0a158ebf128121a31ed417f4bc9c98645344f98 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 23 Feb 2021 16:29:54 -0600 Subject: [PATCH 210/349] Raise usage error when updating profile does not receive any argument (#250) --- src/code42cli/cmds/profile.py | 9 +++++++++ tests/cmds/test_profile.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 53204155c..a4772c77d 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -60,6 +60,7 @@ def username_option(required=False): is_flag=True, help="For development purposes, do not validate the SSL certificates of Code42 servers. " "This is not recommended, except for specific scenarios like testing.", + default=None, ) @@ -104,11 +105,19 @@ def create(name, server, username, password, disable_ssl_errors): def update(name, server, username, password, disable_ssl_errors): """Update an existing profile.""" c42profile = cliprofile.get_profile(name) + + if not server and not username and not password and disable_ssl_errors is None: + raise click.UsageError( + "Must provide at least one of `--username`, `--server`, `--password`, or " + "`--disable-ssl-errors` when updating a profile." + ) + cliprofile.update_profile(c42profile.name, server, username, disable_ssl_errors) if password: _set_pw(name, password) elif not c42profile.has_stored_password: _prompt_for_allow_password_set(c42profile.name) + echo("Profile '{}' has been updated.".format(c42profile.name)) diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index b01b7a7e3..82478371e 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -357,6 +357,22 @@ def test_update_profile_if_user_agrees_and_valid_connection_sets_password( ) +def test_update_profile_when_given_zero_args_prints_error_message( + runner, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + profile.ignore_ssl_errors = False + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke(cli, ["profile", "update"]) + expected = ( + "Must provide at least one of `--username`, `--server`, `--password`, " + "or `--disable-ssl-errors` when updating a profile." + ) + assert "Profile 'foo' has been updated" not in result.output + assert expected in result.output + + def test_delete_profile_warns_if_deleting_default(runner, mock_cliprofile_namespace): mock_cliprofile_namespace.is_default_profile.return_value = True result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) From 8894e8f8ac25e130cbc9af3d3178f4f8891354ce Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 24 Feb 2021 08:09:18 -0600 Subject: [PATCH 211/349] handle 3.8+ pandas adding a newline at end (#249) --- tests/test_output_formats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index 9be0e4bc6..dd37148d6 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -793,7 +793,7 @@ def test_json_formatter_converts_to_expected_string(self): formatter = DataFrameOutputFormatter(OutputFormat.JSON) output = formatter.get_formatted_output(self.test_df) assert ( - output + output.strip() == '{"string_column":"string1","int_column":42,"null_column":null}\n{"string_column":"string2","int_column":43,"null_column":null}' ) From adf3f444e34b57221dabffd986f332e3880319cb Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Thu, 25 Feb 2021 15:13:56 -0600 Subject: [PATCH 212/349] prep for 1.3.1 (#251) --- CHANGELOG.md | 4 ++-- src/code42cli/__version__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e83ec4f3..3aa090bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.3.1 - 2021-02-25 ### Changed - Command options for `profile update`: - - `-n` `--name` is not required, and if omitted with use default profile. + - `-n` `--name` is not required, and if omitted will use the default profile. - `-s` `--server` and `-u` `--username` are not required and can be updated independently now. - Example: `code42 profile update -s 1.2.3.4:1234` diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 67bc602ab..9c73af26b 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.3.0" +__version__ = "1.3.1" From 65c5426bd887b10c280bef23cf871a14e5b716c9 Mon Sep 17 00:00:00 2001 From: maddie-vargo <75453991+maddie-vargo@users.noreply.github.com> Date: Mon, 1 Mar 2021 10:51:12 -0600 Subject: [PATCH 213/349] Add legal hold membership to device reporting (#192) * Legal Hold work to meet Issue 176 * Fix to Changelog * Minor fix to CHANGELOG * Added to legal hold user guide * Adjusting build parameters to bypass 3.5 for this PR * Fix low hanging fruit for initial PR review * remove whitespaces that are coming through as edits * fix changes identfied by tox style run * remove duplication in setup.py - file should have no edits * remove duplication in setup.py - file should have no edits * refactor membership function to use generator and remove NaNs from output * fix tox style run issue * Fix tox style run x2 * flipping back to using NaN, awaiting PR #245 * Adding --include-total-storage option, which calculates total number of archives and archive bytes * Remove V2 archives from storage calcuation; rename columns * fix small change to the incldue/excluded archive types * reword * conflict reconciliation in changelog, part I * conflict reconciliation in changelog, part II (repulled from upstream master * fix style run --- CHANGELOG.md | 6 ++ src/code42cli/cmds/devices.py | 87 +++++++++++++++- tests/cmds/test_devices.py | 183 +++++++++++++++++++++++++++++++++- 3 files changed, 274 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa090bde..a06ac9cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Now, when adding a cloud alias to a detection list user, such as during `departing-employee add`, it will remove the existing cloud alias if one exists. - Before, it would error and the cloud alias would not get added. +### Added + +- `code42 devices list` option: + - `--include-legal-hold-membership` prints the legal hold matter name and ID for any active device on legal hold + - `--include-total-storage` prints the backup archive count and total storage + ## 1.0.0 - 2020-08-31 ### Fixed diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 426ddb019..52e9af338 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -1,8 +1,11 @@ from datetime import date import click +import numpy as np from pandas import concat from pandas import DataFrame +from pandas import json_normalize +from pandas import Series from pandas import to_datetime from py42 import exceptions from py42.exceptions import Py42NotFoundError @@ -244,6 +247,22 @@ def _get_device_info(sdk, device_guid): is_flag=True, help="Include device settings in output.", ) +@click.option( + "--include-legal-hold-membership", + required=False, + type=bool, + default=False, + is_flag=True, + help="Include legal hold membership in output.", +) +@click.option( + "--include-total-storage", + required=False, + type=bool, + default=False, + is_flag=True, + help="Include backup archive count and total storage in output.", +) @click.option( "--exclude-most-recently-connected", type=int, @@ -285,6 +304,8 @@ def list_devices( include_backup_usage, include_usernames, include_settings, + include_legal_hold_membership, + include_total_storage, exclude_most_recently_connected, last_connected_after, last_connected_before, @@ -309,7 +330,11 @@ def list_devices( "userUid", ] df = _get_device_dataframe( - state.sdk, columns, active, org_uid, include_backup_usage + state.sdk, + columns, + active, + org_uid, + (include_backup_usage or include_total_storage), ) if last_connected_after: df = df.loc[to_datetime(df.lastConnected) > last_connected_after] @@ -326,10 +351,14 @@ def list_devices( .head(exclude_most_recently_connected) ) df = df.drop(most_recent.index) + if include_total_storage: + df = _add_storage_totals_to_dataframe(df, include_backup_usage) if include_settings: df = _add_settings_to_dataframe(state.sdk, df) if include_usernames: df = _add_usernames_to_device_dataframe(state.sdk, df) + if include_legal_hold_membership: + df = _add_legal_hold_membership_to_device_dataframe(state.sdk, df) if df.empty: click.echo("No results found.") else: @@ -337,6 +366,42 @@ def list_devices( formatter.echo_formatted_dataframe(df) +def _add_legal_hold_membership_to_device_dataframe(sdk, df): + columns = ["legalHold.legalHoldUid", "legalHold.name", "user.userUid"] + + legal_hold_member_dataframe = ( + json_normalize(list(_get_all_active_hold_memberships(sdk)))[columns] + .groupby(["user.userUid"]) + .agg(",".join) + .rename( + { + "legalHold.legalHoldUid": "legalHoldUid", + "legalHold.name": "legalHoldName", + }, + axis=1, + ) + ) + df = df.merge( + legal_hold_member_dataframe, + how="left", + left_on="userUid", + right_on="user.userUid", + ) + + df.loc[df["status"] == "Deactivated", ["legalHoldUid", "legalHoldName"]] = np.nan + + return df + + +def _get_all_active_hold_memberships(sdk): + for page in sdk.legalhold.get_all_matters(active=True): + for matter in page["legalHolds"]: + for _page in sdk.legalhold.get_all_matter_custodians( + legal_hold_uid=matter["legalHoldUid"], active=True + ): + yield from _page["legalHoldMemberships"] + + def _get_device_dataframe( sdk, columns, active=None, org_uid=None, include_backup_usage=False ): @@ -392,6 +457,26 @@ def _add_usernames_to_device_dataframe(sdk, device_dataframe): return device_dataframe.merge(users_dataframe, how="left", on="userUid") +def _add_storage_totals_to_dataframe(df, include_backup_usage): + df[["archiveCount", "totalStorageBytes"]] = df["backupUsage"].apply( + _break_backup_usage_into_total_storage + ) + + if not include_backup_usage: + df = df.drop("backupUsage", axis=1) + return df + + +def _break_backup_usage_into_total_storage(backup_usage): + total_storage = 0 + archive_count = 0 + for archive in backup_usage: + if archive["archiveFormat"] != "ARCHIVE_V2": + archive_count += 1 + total_storage += archive["archiveBytes"] + return Series([archive_count, total_storage]) + + @devices.command() @active_option @inactive_option diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 781eb2ca3..31b5062dc 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -1,7 +1,12 @@ +import json from datetime import date +import numpy as np import pytest from pandas import DataFrame +from pandas import Series +from pandas._testing import assert_frame_equal +from pandas._testing import assert_series_equal from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42NotFoundError @@ -10,7 +15,9 @@ from code42cli import PRODUCT_NAME from code42cli.cmds.devices import _add_backup_set_settings_to_dataframe +from code42cli.cmds.devices import _add_legal_hold_membership_to_device_dataframe from code42cli.cmds.devices import _add_usernames_to_device_dataframe +from code42cli.cmds.devices import _break_backup_usage_into_total_storage from code42cli.cmds.devices import _get_device_dataframe from code42cli.main import cli @@ -76,7 +83,19 @@ "836476656572622471","serverName":"cif-sea","serverHostName":"https://cif-sea.crashplan.com", "isProvider":false,"archiveGuid":"843293524842941560","archiveFormat":"ARCHIVE_V1","activity": {"connected":false,"backingUp":false,"restoring":false,"timeRemainingInMs":0, -"remainingFiles":0,"remainingBytes":0}}]}}""" +"remainingFiles":0,"remainingBytes":0}},{"targetComputerParentId":null,"targetComputerParentGuid": +null,"targetComputerGuid":"43","targetComputerName":"PROe Cloud, US","targetComputerOsName":null, +"targetComputerType":"SERVER","selectedFiles":1599,"selectedBytes":1529420143,"todoFiles":0, +"todoBytes":0,"archiveBytes":56848550,"billableBytes":1529420143,"sendRateAverage":0, +"completionRateAverage":0,"lastBackup":"2019-12-02T09:34:28.364-06:00","lastCompletedBackup": +"2019-12-02T09:34:28.364-06:00","lastConnected":"2019-12-02T11:02:36.108-06:00","lastMaintenanceDate": +"2021-02-16T07:01:11.697-06:00","lastCompactDate":"2021-02-16T07:01:11.694-06:00","modificationDate": +"2021-02-17T04:57:27.222-06:00","creationDate":"2019-09-26T15:27:38.806-05:00","using":true, +"alertState":16,"alertStates":["CriticalBackupAlert"],"percentComplete":100.0,"storePointId":10989, +"storePointName":"fsa-iad-2","serverId":160024121,"serverGuid":"883282371081742804","serverName": +"fsa-iad","serverHostName":"https://web-fsa-iad.crashplan.com","isProvider":false,"archiveGuid": +"92077743916530001","archiveFormat":"ARCHIVE_V1","activity":{"connected":false,"backingUp":false, +"restoring":false,"timeRemainingInMs":0,"remainingFiles":0,"remainingBytes":0}}]}}""" TEST_EMPTY_BACKUPUSAGE_RESPONSE = """{"metadata":{"timestamp":"2020-10-13T12:51:28.410Z","params": {"incBackupUsage":"True","idType":"guid"}},"data":{"computerId":1767,"name":"SNWINTEST1", "osHostname":"UNKNOWN","guid":"843290890230648046","type":"COMPUTER","status":"Active", @@ -221,6 +240,75 @@ }, ], } +MATTER_RESPONSE = { + "legalHolds": [ + { + "legalHoldUid": "123456789", + "name": "Test legal hold matter", + "description": "", + "notes": None, + "holdExtRef": None, + "active": True, + "creationDate": "2020-08-05T10:49:58.353-05:00", + "lastModified": "2020-08-05T10:49:58.358-05:00", + "creator": { + "userUid": "12345", + "username": "user@code42.com", + "email": "user@code42.com", + "userExtRef": None, + }, + "holdPolicyUid": "966191295667423997", + }, + { + "legalHoldUid": "987654321", + "name": "Another Matter", + "description": "", + "notes": None, + "holdExtRef": None, + "active": True, + "creationDate": "2020-05-20T15:58:31.375-05:00", + "lastModified": "2020-05-28T13:49:16.098-05:00", + "creator": { + "userUid": "76543", + "username": "user2@code42.com", + "email": "user2@code42.com", + "userExtRef": None, + }, + "holdPolicyUid": "946178665645035826", + }, + ] +} +ALL_CUSTODIANS_RESPONSE = { + "legalHoldMemberships": [ + { + "legalHoldMembershipUid": "99999", + "active": True, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": { + "legalHoldUid": "123456789", + "name": "Test legal hold matter", + }, + "user": { + "userUid": "840103986007089121", + "username": "ttranda_deactivated@ttrantest.com", + "email": "ttranda_deactivated@ttrantest.com", + "userExtRef": None, + }, + }, + { + "legalHoldMembershipUid": "88888", + "active": True, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": {"legalHoldUid": "987654321", "name": "Another Matter"}, + "user": { + "userUid": "840103986007089121", + "username": "ttranda_deactivated@ttrantest.com", + "email": "ttranda_deactivated@ttrantest.com", + "userExtRef": None, + }, + }, + ] +} def _create_py42_response(mocker, text): @@ -274,6 +362,14 @@ def users_list_generator(): yield TEST_USERS_LIST_PAGE +def matter_list_generator(): + yield MATTER_RESPONSE + + +def custodian_list_generator(): + yield ALL_CUSTODIANS_RESPONSE + + @pytest.fixture def backupusage_response(mocker): return _create_py42_response(mocker, TEST_BACKUPUSAGE_RESPONSE) @@ -356,6 +452,18 @@ def get_all_users_success(cli_state): cli_state.sdk.users.get_all.return_value = users_list_generator() +@pytest.fixture +def get_all_matter_success(cli_state): + cli_state.sdk.legalhold.get_all_matters.return_value = matter_list_generator() + + +@pytest.fixture +def get_all_custodian_success(cli_state): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + custodian_list_generator() + ) + + def test_deactivate_deactivates_device( runner, cli_state, deactivate_device_success, get_device_by_guid_success ): @@ -558,6 +666,79 @@ def test_add_usernames_to_device_dataframe_adds_usernames_to_dataframe( assert "username" in result.columns +def test_add_legal_hold_membership_to_device_dataframe_adds_legal_hold_columns_to_dataframe( + cli_state, get_all_matter_success, get_all_custodian_success +): + testdf = DataFrame.from_records( + [ + {"userUid": "840103986007089121", "status": "Active"}, + {"userUid": "836473273124890369", "status": "Active, Deauthorized"}, + ] + ) + result = _add_legal_hold_membership_to_device_dataframe(cli_state.sdk, testdf) + assert "legalHoldUid" in result.columns + assert "legalHoldName" in result.columns + + +def test_list_include_legal_hold_membership_pops_legal_hold_if_device_deactivated( + cli_state, get_all_matter_success, get_all_custodian_success +): + testdf = DataFrame.from_records( + [ + {"userUid": "840103986007089121", "status": "Deactivated"}, + {"userUid": "840103986007089121", "status": "Active"}, + ] + ) + + testdf_result = DataFrame.from_records( + [ + { + "userUid": "840103986007089121", + "status": "Deactivated", + "legalHoldUid": np.nan, + "legalHoldName": np.nan, + }, + { + "userUid": "840103986007089121", + "status": "Active", + "legalHoldUid": "123456789,987654321", + "legalHoldName": "Test legal hold matter,Another Matter", + }, + ] + ) + result = _add_legal_hold_membership_to_device_dataframe(cli_state.sdk, testdf) + + assert_frame_equal(result, testdf_result) + + +def test_list_include_legal_hold_membership_merges_in_and_concats_legal_hold_info( + runner, + cli_state, + get_all_devices_success, + get_all_custodian_success, + get_all_matter_success, +): + result = runner.invoke( + cli, ["devices", "list", "--include-legal-hold-membership"], obj=cli_state + ) + + assert "Test legal hold matter,Another Matter" in result.output + assert "123456789,987654321" in result.output + + +def test_break_backup_usage_into_total_storage_correctly_calculates_values(): + test_backupusage_cell = json.loads(TEST_BACKUPUSAGE_RESPONSE)["data"]["backupUsage"] + result = _break_backup_usage_into_total_storage(test_backupusage_cell) + + test_empty_backupusage_cell = json.loads(TEST_EMPTY_BACKUPUSAGE_RESPONSE)["data"][ + "backupUsage" + ] + empty_result = _break_backup_usage_into_total_storage(test_empty_backupusage_cell) + + assert_series_equal(result, Series([2, 56968051])) + assert_series_equal(empty_result, Series([0, 0])) + + def test_last_connected_after_filters_appropriate_results( cli_state, runner, get_all_devices_success ): From db7e49e905e6dcb4b108de6e0daf8efca3de1eb8 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 1 Mar 2021 13:35:51 -0600 Subject: [PATCH 214/349] Feature/extension scripts (#224) * initial work for extension scripts * handle cmd construction on Windows * use next(iter(dict)) instead of list(dict.keys)[0] * require --python to be first arg to trigger interpreter * adjust arg swap given we know it's arg[0] * remove cmd name if there's only one * rename --python option to --script, document option and add docstring to InterpreterGroup * alias sdk_options in extensions.py * style * allow single commands to execute when no args/options required * style * just print executable path instead of exec-ing directly * remove unused imports * test installable plugin * delete test plugin folder * add required lib, style * style * exit if --python is passed to not allow subcommands * make extensions a module * add unused import ignore to setup.cfg, document import to prevent future removal * Add docs and redefine sdk_options to be regular decorator instead of returning a decorator * style * update changelog * Add note on extensions to README * style * code42cli > Code42 CLI * remove ignore since we're using all imports now * comma * make readable w linebreaks --- CHANGELOG.md | 7 ++ README.md | 5 ++ docs/guides.md | 1 + docs/userguides/extensions.md | 101 +++++++++++++++++++++++++++ setup.py | 1 + src/code42cli/click_ext/groups.py | 18 +++++ src/code42cli/extensions/__init__.py | 49 +++++++++++++ src/code42cli/main.py | 22 +++++- src/code42cli/options.py | 4 +- 9 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 docs/userguides/extensions.md create mode 100644 src/code42cli/extensions/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a06ac9cd0..b97a986de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- `code42cli.extensions` module exposes `sdk_options` decorator and `script` group for writing custom extension scripts + using the Code42 CLI. + ## 1.3.1 - 2021-02-25 ### Changed diff --git a/README.md b/README.md index 9a84d64c5..70e4d4ee4 100644 --- a/README.md +++ b/README.md @@ -273,3 +273,8 @@ eval (env _CODE42_COMPLETE=source_fish code42) ``` Open a new shell to enable completion. Or run the eval command directly in your current shell to enable it temporarily. + + +## Writing Extensions + +The CLI exposes a few helpers for writing custom extension scripts powered by the CLI. Read the user-guide [here](https://clidocs.code42.com/en/feature-extension_scripts/userguides/extensions.html). diff --git a/docs/guides.md b/docs/guides.md index 6aa4243e6..ba4e55d47 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -5,3 +5,4 @@ * [Ingest file events or alerts into a SIEM](userguides/siemexample.md) * [Manage detection list users](userguides/detectionlists.md) * [Manage legal hold users](userguides/legalhold.md) +* [Write custom extension scripts using the Code42 CLI and py42](userguides/extensions.md) diff --git a/docs/userguides/extensions.md b/docs/userguides/extensions.md new file mode 100644 index 000000000..defadeea1 --- /dev/null +++ b/docs/userguides/extensions.md @@ -0,0 +1,101 @@ +# Write custom extension scripts using the Code42 CLI and py42 + +While the Code42 CLI aims to provide an easy way to automate many common Code42 tasks, there will likely be times when +you need to script something the CLI doesn't have out-of-the-box. + +To accommodate for those scenarios, the Code42 CLI exposes a few helper objects in the `code42cli.extensions` module +that make it easy to write custom scripts with `py42` that use features of the CLI (like profiles) to reduce the amount +of boilerplate needed to be productive. + +## Before you begin + +The Code42 CLI is a python application written using the [click framework](https://click.palletsprojects.com/en/7.x/), +and the exposed extension objects are custom `click` classes. A basic knowledge of how to define `click` commands, +arguments, and options is required. + +### The `sdk_options` decorator + +The most important extension object is the `sdk_options` decorator. When you decorate a command you've defined in your +script with `@sdk_options`, it will automatically add `--profile` and `--debug` options to your command. These work the +same as in the main CLI commands. + +Decorating a command with `@sdk_options` also causes the first argument to your command function to be the `state` +object, which contains the initialized py42 sdk. There's no need to handle user credentials or login, the `sdk_options` +does all that for you using the CLI profiles. + +### The `script` group + +The `script` object exposed in the extensions module is a `click.Group` subclass, which allows you to add multiple +sub-commands and group functionality together. While not explicitly required when writing custom scripts, the `script` +group has logic to help handle and log any uncaught exceptions to the `~/.code42cli/log/code42_errors.log` file. + +If only a single command is added to the `script` group, the group will default to that command, so you don't need to +explicitly provide the sub-command name. + +An example command that just prints the username and ID that the sdk is authenticated with: + +```python +import click +from code42cli.extensions import script, sdk_options + +@click.command() +@sdk_options +def my_command(state): + user = state.sdk.users.get_current() + print(user["username"], user["userId"]) + +if __name__ == "__main__": + script.add_command(my_command) + script() +``` + +## Ensuring your script runs in the Code42 CLI python environment + +The above example works as a standalone script, if it were named `my_script.py` you could execute it by running: + +```bash +python3 my_script.py +``` + +However, if the Code42 CLI is installed in a different python environment than your `python3` command, it might fail to +import the extensions. + +To workaround environment and path issues, the CLI has a `--python` option that prints out the path to the python +executable the CLI uses, so you can execute your script with`$(code42 --python) script.py` on Mac/Linux or +`&$(code42 --python) script.py` on Windows to ensure it always uses the correct python path for the extension script to +work. + +## Installing your extension script as a Code42 CLI plugin + +The above example works as a standalone script, but it's also possible to install that same script as a plugin into the +main CLI itself. + +Assuming the above example code is in a file called `my_script.py`, just add a file `setup.py` in the same directory +with the following: + +```python +from distutils.core import setup + +setup( + name="my_script", + version="0.1", + py_modules=["my_script"], + install_requires=["code42cli"], + entry_points=""" + [code42cli.plugins] + my_command=my_script:my_command + """, +) +``` + +The `entry_points` section tells the Code42 CLI where to look for the commands to add to its main group. If you have +multiple commands defined in your script you can add one per line in the `entry_points` and they'll all get installed +into the Code42 CLI. + +Once your `setup.py` is ready, install it with pip while in the directory of `setup.py`: + +``` +$(code42 --python) -m pip install . +``` + +Then running `code42 -h` should show `my-command` as one of the available commands to run! diff --git a/setup.py b/setup.py index bb8370549..a357a7619 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4", install_requires=[ "click>=7.1.1", + "click_plugins>=1.1.1", "colorama>=0.4.3", "c42eventextractor==0.4.0", "keyring==18.0.1", diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index 7745a0526..bdf559eec 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -120,3 +120,21 @@ def __init__(self, name=None, commands=None, **attrs): def list_commands(self, ctx): return self.commands + + +class ExtensionGroup(ExceptionHandlingGroup): + """A helper click.Group for extension scripts. If only a single command is added to this group, + that command will be the "default" and won't need to be explicitly passed as the first argument + to the extension script. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def parse_args(self, ctx, args): + if len(self.commands) == 1: + cmd_name, cmd = next(iter(self.commands.items())) + if not args or args[0] not in self.commands: + self.commands = {"": cmd} + args.insert(0, "") + super().parse_args(ctx, args) diff --git a/src/code42cli/extensions/__init__.py b/src/code42cli/extensions/__init__.py new file mode 100644 index 000000000..063a13217 --- /dev/null +++ b/src/code42cli/extensions/__init__.py @@ -0,0 +1,49 @@ +from code42cli.click_ext.groups import ExtensionGroup +from code42cli.main import CONTEXT_SETTINGS +from code42cli.options import debug_option +from code42cli.options import pass_state +from code42cli.options import profile_option + + +def sdk_options(f): + """Decorator that adds two `click.option`s (--profile, --debug) to wrapped command, as well as + passing the `code42cli.options.CLIState` object using the [click.make_pass_decorator](https://click.palletsprojects.com/en/7.x/api/#click.make_pass_decorator), + which automatically instantiates the `py42` sdk using the Code42 profile provided from the `--profile` + option. The `py42` sdk can be accessed from the `state.sdk` attribute. + + Example: + + @click.command() + @sdk_options + def get_current_user_command(state): + my_user = state.sdk.users.get_current() + print(my_user) + """ + f = profile_option()(f) + f = debug_option()(f) + f = pass_state(f) + return f + + +script = ExtensionGroup(context_settings=CONTEXT_SETTINGS) +"""A `click.Group` subclass that enables the Code42 CLI's custom error handling/logging to be used +in extension scripts. If only a single command is added to the `script` group it also uses that +command as the default, so the command name doesn't need to be called explicitly. + +Example: + + @click.command() + @click.argument("guid") + @sdk_options + def get_device_info(state, guid) + device = state.sdk.devices.get_by_guid(guid) + print(device) + + if __name__ == "__main__": + script.add_command(my_command) + script() + +The script can then be invoked directly without needing to call the `get-device-info` subcommand: + + python script.py --profile my_profile +""" diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 2c6c5bba1..d4876241f 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -2,6 +2,8 @@ import sys import click +from click_plugins import with_plugins +from pkg_resources import iter_entry_points from py42.__version__ import __version__ as py42version from py42.settings import set_user_agent_suffix @@ -50,10 +52,24 @@ def exit_on_interrupt(signal, frame): } -@click.group(cls=ExceptionHandlingGroup, context_settings=CONTEXT_SETTINGS, help=BANNER) +@with_plugins(iter_entry_points("code42cli.plugins")) +@click.group( + cls=ExceptionHandlingGroup, + context_settings=CONTEXT_SETTINGS, + help=BANNER, + invoke_without_command=True, + no_args_is_help=True, +) +@click.option( + "--python", + is_flag=True, + help="Print path to the python interpreter env that `code42` is installed in.", +) @sdk_options(hidden=True) -def cli(state): - pass +def cli(state, python): + if python: + click.echo(sys.executable) + sys.exit(0) cli.add_command(alerts) diff --git a/src/code42cli/options.py b/src/code42cli/options.py index ca4302028..5f4d6c739 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -68,14 +68,14 @@ def set_assume_yes(self, param): def set_profile(ctx, param, value): """Sets the profile on the global state object when --profile is passed to commands - decorated with @global_options.""" + decorated with @sdk_options.""" if value: ctx.ensure_object(CLIState).profile = get_profile(value) def set_debug(ctx, param, value): """Sets debug to True on global state object when --debug/-d is passed to commands decorated - with @global_options. + with @sdk_options. """ if value: ctx.ensure_object(CLIState).debug = value From 4ecf9451b6df8743137cf4b640a963778a637ff8 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 2 Mar 2021 13:40:12 -0600 Subject: [PATCH 215/349] Fix intermittent send-to integration failures (#254) * move DataServers into session-scoped fixtures with different ports to prevent intermittent connection problems * style --- tests/integration/conftest.py | 13 ++++++++++++ tests/integration/test_alerts.py | 26 +++++++++++++++--------- tests/integration/test_auditlogs.py | 26 +++++++++++++++--------- tests/integration/test_securitydata.py | 28 +++++++++++++++++--------- tests/integration/util.py | 2 +- 5 files changed, 64 insertions(+), 31 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 344ad95f6..4b389e107 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -2,6 +2,7 @@ from shlex import split as split_command import pytest +from tests.integration.util import DataServer from code42cli.errors import Code42CLIError from code42cli.main import cli @@ -46,3 +47,15 @@ def _encode_response(line, encoding_type=_ENCODING_TYPE): def append_profile(command): return "{} --profile {}".format(command, TEST_PROFILE_NAME) + + +@pytest.yield_fixture(scope="session") +def udp_dataserver(): + with DataServer(protocol="UDP"): + yield + + +@pytest.yield_fixture(scope="session") +def tcp_dataserver(): + with DataServer(protocol="TCP"): + yield diff --git a/tests/integration/test_alerts.py b/tests/integration/test_alerts.py index 27a723fd8..f08334ab2 100644 --- a/tests/integration/test_alerts.py +++ b/tests/integration/test_alerts.py @@ -5,7 +5,6 @@ import pytest from tests.integration.conftest import append_profile from tests.integration.util import assert_test_is_successful -from tests.integration.util import DataServer from code42cli.main import cli @@ -24,17 +23,24 @@ def test_alerts_search_command_returns_success_return_code( @pytest.mark.integration -@pytest.mark.parametrize( - "protocol", ["TCP", "UDP"], -) -def test_alerts_send_to_returns_success_return_code( - runner, integration_test_profile, protocol +def test_alerts_send_to_tcp_returns_success_return_code( + runner, integration_test_profile, tcp_dataserver ): - command = "alerts send-to localhost:5140 -p {} -b {}".format( - protocol, begin_date_str + command = append_profile( + f"alerts send-to localhost:5140 -p TCP -b '{begin_date_str}'" ) - with DataServer(protocol=protocol): - result = runner.invoke(cli, split_command(append_profile(command))) + result = runner.invoke(cli, split_command(command)) + assert result.exit_code == 0 + + +@pytest.mark.integration +def test_alerts_send_to_udp_returns_success_return_code( + runner, integration_test_profile, udp_dataserver +): + command = append_profile( + f"alerts send-to localhost:5141 -p UDP -b '{begin_date_str}'" + ) + result = runner.invoke(cli, split_command(command)) assert result.exit_code == 0 diff --git a/tests/integration/test_auditlogs.py b/tests/integration/test_auditlogs.py index 7fd000d5e..2fc6951bf 100644 --- a/tests/integration/test_auditlogs.py +++ b/tests/integration/test_auditlogs.py @@ -5,7 +5,6 @@ import pytest from tests.integration.conftest import append_profile from tests.integration.util import assert_test_is_successful -from tests.integration.util import DataServer from code42cli.main import cli @@ -17,17 +16,24 @@ @pytest.mark.integration -@pytest.mark.parametrize( - "protocol", ["TCP", "UDP"], -) -def test_auditlogs_send_to_command_returns_success_return_code( - runner, integration_test_profile, protocol +def test_auditlogs_send_to_tcp_command_returns_success_return_code( + runner, integration_test_profile, tcp_dataserver ): - command = "audit-logs send-to localhost:5140 -p {} -b '{}'".format( - protocol, begin_date_str + command = append_profile( + f"audit-logs send-to localhost:5140 -p TCP -b '{begin_date_str}'" ) - with DataServer(protocol=protocol): - result = runner.invoke(cli, split_command(append_profile(command))) + result = runner.invoke(cli, split_command(command)) + assert result.exit_code == 0 + + +@pytest.mark.integration +def test_auditlogs_send_to_udp_command_returns_success_return_code( + runner, integration_test_profile, udp_dataserver +): + command = append_profile( + f"audit-logs send-to localhost:5141 -p UDP -b '{begin_date_str}'" + ) + result = runner.invoke(cli, split_command(command)) assert result.exit_code == 0 diff --git a/tests/integration/test_securitydata.py b/tests/integration/test_securitydata.py index 9d89ffc44..ca5a6c858 100644 --- a/tests/integration/test_securitydata.py +++ b/tests/integration/test_securitydata.py @@ -4,23 +4,31 @@ import pytest from tests.integration.conftest import append_profile -from tests.integration.util import DataServer from code42cli.main import cli @pytest.mark.integration -@pytest.mark.parametrize( - "protocol", ["TCP", "UDP"], -) -def test_security_data_send_to_return_success_return_code( - runner, integration_test_profile, protocol +def test_security_data_send_to_tcp_return_success_return_code( + runner, integration_test_profile, tcp_dataserver ): begin_date = datetime.utcnow() - timedelta(days=20) begin_date_str = begin_date.strftime("%Y-%m-%d") - command = "security-data send-to localhost:5140 -p {} -b {}".format( - protocol, begin_date_str + command = append_profile( + f"security-data send-to localhost:5140 -p TCP -b '{begin_date_str}'" ) - with DataServer(protocol=protocol): - result = runner.invoke(cli, split_command(append_profile(command))) + result = runner.invoke(cli, split_command(command)) + assert result.exit_code == 0 + + +@pytest.mark.integration +def test_security_data_send_to_udp_return_success_return_code( + runner, integration_test_profile, udp_dataserver +): + begin_date = datetime.utcnow() - timedelta(days=20) + begin_date_str = begin_date.strftime("%Y-%m-%d") + command = append_profile( + f"security-data send-to localhost:5141 -p UDP -b '{begin_date_str}'" + ) + result = runner.invoke(cli, split_command(command)) assert result.exit_code == 0 diff --git a/tests/integration/util.py b/tests/integration/util.py index 25979093c..37b8231c2 100644 --- a/tests/integration/util.py +++ b/tests/integration/util.py @@ -38,7 +38,7 @@ def wrapper(): class DataServer: TCP_SERVER_COMMAND = "ncat -l 5140" - UDP_SERVER_COMMAND = "ncat -ul 5140" + UDP_SERVER_COMMAND = "ncat -ul 5141" def __init__(self, protocol="TCP"): if protocol.upper() == "UDP": From aac2684cc5a60a5108b6e37c482d1cdc1bbbc287 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 3 Mar 2021 11:53:03 -0600 Subject: [PATCH 216/349] [Feature] add deactivate devices guide (#252) Co-authored-by: Alan Grgic --- docs/guides.md | 1 + docs/userguides/deactivatedevices.md | 97 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 docs/userguides/deactivatedevices.md diff --git a/docs/guides.md b/docs/guides.md index ba4e55d47..1000a178d 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -5,4 +5,5 @@ * [Ingest file events or alerts into a SIEM](userguides/siemexample.md) * [Manage detection list users](userguides/detectionlists.md) * [Manage legal hold users](userguides/legalhold.md) +* [Clean up your environment by deactivating devices](userguides/deactivatedevices.md) * [Write custom extension scripts using the Code42 CLI and py42](userguides/extensions.md) diff --git a/docs/userguides/deactivatedevices.md b/docs/userguides/deactivatedevices.md new file mode 100644 index 000000000..afe641531 --- /dev/null +++ b/docs/userguides/deactivatedevices.md @@ -0,0 +1,97 @@ +# Clean up your environment by deactivating devices + +Your Code42 environment may contain many old devices that are no +longer active computers and that have not connected to Code42 in +quite some time. In order to clean up your environment, you can +use the CLI to deactivate these devices in bulk. + +## Generate a list of devices + +You can generate a list of devices using `code42 devices list`. By +default, it will display the list of devices at the command line, +but you can also output it in a number of file formats. For +example, to generate a CSV of devices in your environment, use +this command: + +``` +code42 devices list -f CSV +``` + +To save to a file, redirect the output to a file in your shell: + +``` +code42 devices list -f CSV > output.csv +``` + +### Filter the list + +You can filter or edit the list of devices in your spreadsheet or +text editor of choice, but the CLI has some parameters built in +that can help you to filter the list of devices to just the ones +you want to deactivate. To see a full list of available +parameters, run `code42 devices list -h`. + +Here are some useful parameters you may wish to leverage when +curating a list of devices to deactivate: + +* `--last-connected-before DATE|TIMESTAMP|SHORT_TIME` - allows you to only see devices that have not connected since a particular date. You can also use a timestamp or short time format, for example `30d`. +* `--exclude-most-recently-connected INTEGER` - allows you to exclude the most recently connected device (per user) from the results. This allows you to ensure that every user is left with at least N device(s), regardless of how recently they have connected. +* `--created-before DATE|TIMESTAMP|SHORT_TIME` - allows you to only see devices created before a particular date. + +## Deactivate devices + +Once you have a list of devices that you want to remove, you can +run the `code42 devices bulk deactivate` command: + +``` +code42 devices bulk deactivate list_of_devices.csv +``` + +The device list must be a file in CSV format containing a `guid` +column with the unique identifier of the devices to be +deactivated. The deactivate command can also accept some optional +parameters: + +* `--change-device-name` - prepends `deactivated_` to the beginning of the device name, allowing you to have a record of which devices were deactivated by the CLI and when. +* `--purge-date yyyy-MM-dd` - allows you to change the date on which the deactivated devices' archives will be purged from cold storage. + +To see a full list of available options, run `code42 devices bulk deactivate -h`. + +The `code42 devices bulk deactivate` command will output the guid +of the device to be deactivated, plus a column indicating the +success or failure of the deactivation. To change the format of +this output, use the `-f` or `--format` option. + +You can also redirect the output to a file, for example: + +``` +code42 devices bulk deactivate devices_to_deactivate.csv -f CSV > deactivation_results.csv +``` + +Deactivation will fail if the user running the command does not +have permission to deactivate the device, or if the user owning +the device is on legal hold. + + +### Generate the list and deactivate in a single command + +You can also pipe the output of `code42 devices list` directly to +`code42 devices bulk deactivate`. When using a pipe, make sure to +use `-` as the input argument for `code42 devices bulk deactivate` +to indicate that it should read from standard input. + +Here is an example: + +``` +code42 devices list \ +--last-connected-before 365d \ +--exclude-most-recently-connected 1 \ +-f CSV \ +| code42 devices bulk deactivate - \ +-f CSV \ +> deactivation_results.csv +``` + +This lists all devices that have not connected within a year _and_ +are not a user's most-recently-connected device, and then attempts +to deactivate them. From e0c438b0ff1af444e82e847d11f871d854464646 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 9 Mar 2021 12:04:03 -0600 Subject: [PATCH 217/349] bump version to 1.4.0 and fix changelog (#256) * bump version and fix changelog * style --- CHANGELOG.md | 14 +++++--------- src/code42cli/__version__.py | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b97a986de..c8a5e0213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.4.0 - 2021-03-09 ### Added - `code42cli.extensions` module exposes `sdk_options` decorator and `script` group for writing custom extension scripts using the Code42 CLI. +- `code42 devices list` options: + - `--include-legal-hold-membership` prints the legal hold matter name and ID for any active device on legal hold + - `--include-total-storage` prints the backup archive count and total storage + ## 1.3.1 - 2021-02-25 ### Changed @@ -100,8 +104,6 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `search` to search for audit-logs. - `send-to` to send audit-logs to server. -## Unreleased - ### Changed - `profile_name` argument is now required for `code42 profile delete`, as it was meant to be. @@ -113,12 +115,6 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Now, when adding a cloud alias to a detection list user, such as during `departing-employee add`, it will remove the existing cloud alias if one exists. - Before, it would error and the cloud alias would not get added. -### Added - -- `code42 devices list` option: - - `--include-legal-hold-membership` prints the legal hold matter name and ID for any active device on legal hold - - `--include-total-storage` prints the backup archive count and total storage - ## 1.0.0 - 2020-08-31 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 9c73af26b..3e8d9f946 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.3.1" +__version__ = "1.4.0" From 117edcc8626ff44684da543de48b421e01abb9c1 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 10 Mar 2021 19:54:10 +0000 Subject: [PATCH 218/349] hooking up integration tests with mock servers (#253) --- .github/workflows/build.yml | 31 ++++++++++++++++++++++++++-- src/code42cli/cmds/search/options.py | 6 ++++-- tests/integration/conftest.py | 4 ++-- tests/integration/util.py | 4 +++- tox.ini | 4 +++- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 104d59fa8..be5dfa44e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,8 @@ jobs: steps: - uses: actions/checkout@v2 + with: + path: code42cli - name: Setup Python uses: actions/setup-python@v1 with: @@ -25,8 +27,33 @@ jobs: - name: Install tox run: pip install tox==3.17.1 - name: Run Unit tests - run: tox -e py # Run tox using the version of Python in `PATH` + run: cd code42cli; tox -e py # Run tox using the version of Python in `PATH` - name: Submit coverage report uses: codecov/codecov-action@v1.0.7 with: - file: ./coverage.xml + file: code42cli/coverage.xml + - name: Checkout mock servers + uses: actions/checkout@v2 + with: + repository: code42/code42-mock-servers + path: code42-mock-servers + - name: Add mock servers host addresses + run: | + sudo tee -a /etc/hosts < Date: Thu, 11 Mar 2021 10:52:51 -0600 Subject: [PATCH 219/349] Changed 'bulk add-user' to 'bulk add' in userguide (#257) --- docs/userguides/legalhold.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/userguides/legalhold.md b/docs/userguides/legalhold.md index a3d762be1..d1a641ed5 100644 --- a/docs/userguides/legalhold.md +++ b/docs/userguides/legalhold.md @@ -31,9 +31,9 @@ To get the ID for a matter, enter `code42 legal-hold list`. You can add one or more custodians to a legal hold matter using the Code42 CLI. ### Add multiple custodians -Once you have entered the matter ID and user information in the CSV file, use the `bulk add-user` command with the CSV file path to add multiple custodians at once. For example: +Once you have entered the matter ID and user information in the CSV file, use the `bulk add` command with the CSV file path to add multiple custodians at once. For example: -`code42 legal-hold bulk add-user /Users/admin/add_users_to_legal_hold.csv` +`code42 legal-hold bulk add /Users/admin/add_users_to_legal_hold.csv` ### Add a single custodian From f5813362a60d5abb73d60554c516b39dc1d84484 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 11 Mar 2021 17:41:44 +0000 Subject: [PATCH 220/349] fix bulk remove user (#258) --- docs/userguides/legalhold.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/userguides/legalhold.md b/docs/userguides/legalhold.md index d1a641ed5..3319f8571 100644 --- a/docs/userguides/legalhold.md +++ b/docs/userguides/legalhold.md @@ -56,8 +56,8 @@ To release multiple custodians at once: 1. Enter the matter ID(s) and Code42 usernames to the [CSV file template you generated](#get-csv-template). 2. Save the file to your current working directory. -3. Use the `bulk remove-user` command with the file path of the CSV you created. For example: - `code42 legal-hold bulk remove-user /Users/admin/remove_users_from_legal_hold.csv` +3. Use the `bulk remove` command with the file path of the CSV you created. For example: + `code42 legal-hold bulk remove /Users/admin/remove_users_from_legal_hold.csv` ### Release a single custodian From d63301f1fedfaef4c344528b60e841860298f41e Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Thu, 18 Mar 2021 14:40:25 -0500 Subject: [PATCH 221/349] Update deactivatedevices.md (#260) --- docs/userguides/deactivatedevices.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/userguides/deactivatedevices.md b/docs/userguides/deactivatedevices.md index afe641531..183bb7233 100644 --- a/docs/userguides/deactivatedevices.md +++ b/docs/userguides/deactivatedevices.md @@ -10,17 +10,17 @@ use the CLI to deactivate these devices in bulk. You can generate a list of devices using `code42 devices list`. By default, it will display the list of devices at the command line, but you can also output it in a number of file formats. For -example, to generate a CSV of devices in your environment, use +example, to generate a CSV of active devices in your environment, use this command: ``` -code42 devices list -f CSV +code42 devices list --active -f CSV ``` To save to a file, redirect the output to a file in your shell: ``` -code42 devices list -f CSV > output.csv +code42 devices list --active -f CSV > output.csv ``` ### Filter the list @@ -83,7 +83,7 @@ to indicate that it should read from standard input. Here is an example: ``` -code42 devices list \ +code42 devices list --active \ --last-connected-before 365d \ --exclude-most-recently-connected 1 \ -f CSV \ From 41467c042210e8f4f5aa3c55ac0b4c47ab32a725 Mon Sep 17 00:00:00 2001 From: annie-payseur <52421911+annie-payseur@users.noreply.github.com> Date: Thu, 25 Mar 2021 10:36:43 -0500 Subject: [PATCH 222/349] Update gettingstarted.md (#261) --- docs/userguides/gettingstarted.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index 8e51104ab..26441e132 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -72,7 +72,9 @@ python3 -m pip install code42cli --upgrade .. important:: The Code42 CLI currently only supports token-based authentication. ``` -To use the CLI, you must provide your credentials (basic authentication). If you choose not to store your password in the CLI, you must enter it for each command that requires a connection. +Create a user in Code42 to authenticate (basic authentication) and access data via the CLI. The CLI returns data based on the roles assigned to this user. To ensure that the user's rights are not too permissive, create a user with the lowest level of privilege necessary. See our [Role assignment use cases](https://support.code42.com/Administrator/Cloud/Monitoring_and_managing/Role_assignment_use_cases) for information on recommended roles. We recommend you test to confirm that the user can access the right data. + +If you choose not to store your password in the CLI, you must enter it for each command that requires a connection. The Code42 CLI currently does **not** support SSO login providers or any other identity providers such as Active Directory or Okta. From df5485ecffdef1705cc031666c28956a16ee2de0 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 6 Apr 2021 12:45:46 -0500 Subject: [PATCH 223/349] Bugfix/attempt to autodetect file encodings when reading files (#265) * Auto-detect file encoding on read_csv_arg * add test * use on flat file reader * test ascii * log when no encoding is detected * init logger before logging * move return out of finally to appease flake8 * changelog * make FileOrString auto decoded, add test for FileOrString and file not exist case --- CHANGELOG.md | 7 ++++++ src/code42cli/click_ext/types.py | 20 ++++++++++++++++- src/code42cli/file_readers.py | 5 +++-- tests/test_file_readers.py | 38 ++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a5e0213..b11e2d0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Fixed + +- Arguments/options that read data from files now attempt to autodetect file encodings. Resolving a bug where CSVs written + on Windows with Powershell would fail to be read properly. + ## 1.4.0 - 2021-03-09 ### Added diff --git a/src/code42cli/click_ext/types.py b/src/code42cli/click_ext/types.py index 1baf34b16..be5d80143 100644 --- a/src/code42cli/click_ext/types.py +++ b/src/code42cli/click_ext/types.py @@ -3,11 +3,29 @@ from datetime import timedelta from datetime import timezone +import chardet import click from click.exceptions import BadParameter +from code42cli.logger import CliLogger -class FileOrString(click.File): + +class AutoDecodedFile(click.File): + """Attempts to autodetect file's encoding prior to normal click.File processing.""" + + def convert(self, value, param, ctx): + try: + with open(value, "rb") as file: + self.encoding = chardet.detect(file.read())["encoding"] + if self.encoding is None: + CliLogger().log_error(f"Failed to detect encoding of file: {value}") + except Exception: + pass # we'll let click.File do it's own exception handling for the filepath + + return super().convert(value, param, ctx) + + +class FileOrString(AutoDecodedFile): """Declares a parameter to be a file (if the argument begins with `@`), otherwise accepts it as a string. """ diff --git a/src/code42cli/file_readers.py b/src/code42cli/file_readers.py index 5f0c9768a..2a0e1c92f 100644 --- a/src/code42cli/file_readers.py +++ b/src/code42cli/file_readers.py @@ -2,6 +2,7 @@ import click +from code42cli.click_ext.types import AutoDecodedFile from code42cli.errors import Code42CLIError @@ -13,7 +14,7 @@ def read_csv_arg(headers): return click.argument( "csv_rows", metavar="CSV_FILE", - type=click.File("r"), + type=AutoDecodedFile("r"), callback=lambda ctx, param, arg: read_csv(arg, headers=headers), ) @@ -65,7 +66,7 @@ def read_flat_file(file): read_flat_file_arg = click.argument( "file_rows", - type=click.File("r"), + type=AutoDecodedFile("r"), metavar="FILE", callback=lambda ctx, param, arg: read_flat_file(arg), ) diff --git a/tests/test_file_readers.py b/tests/test_file_readers.py index 627f15aeb..91928c75d 100644 --- a/tests/test_file_readers.py +++ b/tests/test_file_readers.py @@ -1,5 +1,8 @@ +import click.exceptions import pytest +from code42cli.click_ext.types import AutoDecodedFile +from code42cli.click_ext.types import FileOrString from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv @@ -62,3 +65,38 @@ def test_read_csv_when_some_but_not_all_required_headers_present_raises(runner): with open("test_csv.csv") as csv: with pytest.raises(Code42CLIError): read_csv(file=csv, headers=HEADERS + ["extra_header"]) + + +@pytest.mark.parametrize( + "encoding", ["utf8", "utf16", "latin_1"], +) +def test_read_csv_reads_various_encodings_automatically(runner, encoding): + with runner.isolated_filesystem(): + with open("test.csv", "w", encoding=encoding) as file: + file.write("".join(HEADERED_CSV)) + + csv = AutoDecodedFile("r").convert("test.csv", None, None) + result_list = read_csv(csv, headers=HEADERS) + + assert result_list == [ + {"header1": "col1_val1", "header2": "col2_val1", "header3": "col3_val1"}, + {"header1": "col1_val2", "header2": "col2_val2", "header3": "col3_val2"}, + ] + + +def test_AutoDecodedFile_raises_expected_exception_when_file_not_exists(runner): + with pytest.raises(click.exceptions.BadParameter): + AutoDecodedFile("r").convert("not_a_file", None, None) + + +@pytest.mark.parametrize( + "encoding", ["utf8", "utf16", "latin_1"], +) +def test_FileOrString_arg_handles_various_encodings_automatically(runner, encoding): + test_data = '{"tést": "dåta"}' + with runner.isolated_filesystem(): + with open("test1.json", "w", encoding=encoding) as file: + file.write(test_data) + + result_data = FileOrString().convert("@test1.json", None, None) + assert result_data == test_data From d35aa9a5a70501ab8369e5ac744f3a0f4e58102d Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 15 Apr 2021 09:12:34 -0500 Subject: [PATCH 224/349] bump vers (#267) --- CHANGELOG.md | 6 +++--- src/code42cli/__version__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11e2d0e5..d8d410a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.4.1 - 2021-04-15 ### Fixed -- Arguments/options that read data from files now attempt to autodetect file encodings. Resolving a bug where CSVs written - on Windows with Powershell would fail to be read properly. +- Arguments/options that read data from files now attempt to autodetect file encodings. + Resolving a bug where CSVs written on Windows with Powershell would fail to be read properly. ## 1.4.0 - 2021-03-09 diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 3e8d9f946..bf2561596 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.4.0" +__version__ = "1.4.1" From 79e292d81c31056cb24ac6008b0d0989d383b333 Mon Sep 17 00:00:00 2001 From: maddie-vargo <75453991+maddie-vargo@users.noreply.github.com> Date: Thu, 15 Apr 2021 10:00:48 -0500 Subject: [PATCH 225/349] Add events command to legal-hold (#264) --- CHANGELOG.md | 10 ++++ docs/userguides/legalhold.md | 8 +++ src/code42cli/cmds/legal_hold.py | 90 ++++++++++++++++++++++++------ tests/cmds/test_legal_hold.py | 94 +++++++++++++++++++++++++++++++- 4 files changed, 184 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d410a9d..39cf5c2f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## 1.4.1 - 2021-04-15 +### Added + +- `code42 legal-hold search-events` command: + - `--matter-id` filters based on a legal hold uid. + - `--begin` filters based on a beginning timestamp. + - `--end` filters based on an end timestamp. + - `--event-type` filters based on a list of event types. + +## 1.4.1 - 2021-04-15 + ### Fixed - Arguments/options that read data from files now attempt to autodetect file encodings. diff --git a/docs/userguides/legalhold.md b/docs/userguides/legalhold.md index 3319f8571..bfc717860 100644 --- a/docs/userguides/legalhold.md +++ b/docs/userguides/legalhold.md @@ -93,4 +93,12 @@ To view all custodians (including inactive) for a legal hold matter, enter `code42 legal-hold show --include-inactive` +### List legal hold events + +To view a list of legal hold administrative events, use the following command: + +`code42 legal-hold search-events` + +This command takes the optional filters of a specific matter uid, beginning timestamp, end timestamp, and event type. + Learn more about the [Legal Hold](../commands/legalhold.md) commands. diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index ece6bbe34..a7c3882b9 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -1,5 +1,4 @@ import json -from collections import OrderedDict from functools import lru_cache from pprint import pformat @@ -14,33 +13,52 @@ from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.options import sdk_options +from code42cli.options import set_begin_default_dict +from code42cli.options import set_end_default_dict from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter from code42cli.util import format_string_list_to_columns -_MATTER_KEYS_MAP = OrderedDict() -_MATTER_KEYS_MAP["legalHoldUid"] = "Matter ID" -_MATTER_KEYS_MAP["name"] = "Name" -_MATTER_KEYS_MAP["description"] = "Description" -_MATTER_KEYS_MAP["creator_username"] = "Creator" -_MATTER_KEYS_MAP["creationDate"] = "Creation Date" +_MATTER_KEYS_MAP = { + "legalHoldUid": "Matter ID", + "name": "Name", + "description": "Description", + "creator_username": "Creator", + "creationDate": "Creation Date", +} +_EVENT_KEYS_MAP = { + "eventUid": "Event ID", + "eventType": "Event Type", + "eventDate": "Event Date", + "legalHoldUid": "Legal Hold ID", + "actorUsername": "Actor Username", + "custodianUsername": "Custodian Username", +} +LEGAL_HOLD_KEYWORD = "legal hold events" +LEGAL_HOLD_EVENT_TYPES = [ + "MembershipCreated", + "MembershipReactivated", + "MembershipDeactivated", + "HoldCreated", + "HoldDeactivated", + "HoldReactivated", + "Restore", +] +BEGIN_DATE_DICT = set_begin_default_dict(LEGAL_HOLD_KEYWORD) +END_DATE_DICT = set_end_default_dict(LEGAL_HOLD_KEYWORD) @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def legal_hold(state): """Add and remove custodians from legal hold matters.""" - pass -matter_id_option = click.option( - "-m", - "--matter-id", - required=True, - type=str, - help="Identification number of the legal hold matter the custodian will be added to.", -) +def matter_id_option(required, help): + return click.option("-m", "--matter-id", required=required, type=str, help=help) + + user_id_option = click.option( "-u", "--username", @@ -51,7 +69,10 @@ def legal_hold(state): @legal_hold.command() -@matter_id_option +@matter_id_option( + True, + "Identification number of the legal hold matter the custodian will be added to.", +) @user_id_option @sdk_options() def add_user(state, matter_id, username): @@ -60,7 +81,10 @@ def add_user(state, matter_id, username): @legal_hold.command() -@matter_id_option +@matter_id_option( + True, + "Identification number of the legal hold matter the custodian will be removed from.", +) @user_id_option @sdk_options() def remove_user(state, matter_id, username): @@ -124,6 +148,30 @@ def show(state, matter_id, include_inactive=False, include_policy=False): echo("") +@legal_hold.command() +@matter_id_option(False, "Filter results by legal hold UID.") +@click.option( + "--event-type", + type=click.Choice(LEGAL_HOLD_EVENT_TYPES), + help="Filter results by event types.", +) +@click.option("--begin", **BEGIN_DATE_DICT) +@click.option("--end", **END_DATE_DICT) +@format_option +@sdk_options() +def search_events(state, matter_id, event_type, begin, end, format): + """Tools for getting legal hold event data.""" + formatter = OutputFormatter(format, _EVENT_KEYS_MAP) + events = _get_all_events(state.sdk, matter_id, begin, end) + if event_type: + events = [event for event in events if event["eventType"] == event_type] + if len(events) > 10: + output = formatter.get_formatted_output(events) + click.echo_via_pager(output) + else: + formatter.echo_formatted_list(events) + + @legal_hold.group(cls=OrderedGroup) @sdk_options(hidden=True) def bulk(state): @@ -230,6 +278,14 @@ def _get_all_active_matters(sdk): return matters +def _get_all_events(sdk, legal_hold_uid, begin_date, end_date): + events_generator = sdk.legalhold.get_all_events( + legal_hold_uid, begin_date, end_date + ) + events = [event for page in events_generator for event in page["legalHoldEvents"]] + return events + + def _print_matter_members(username_list, member_type="active"): if username_list: echo("\n{} matter members:\n".format(member_type.capitalize())) diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index 4c1124f56..13f6bc2cf 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -1,3 +1,5 @@ +import datetime + import pytest from py42.exceptions import Py42BadRequestError from py42.response import Py42Response @@ -6,9 +8,9 @@ from code42cli import PRODUCT_NAME from code42cli.cmds.legal_hold import _check_matter_is_accessible +from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.main import cli - _NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) TEST_MATTER_ID = "99999" TEST_LEGAL_HOLD_MEMBERSHIP_UID = "88888" @@ -18,6 +20,8 @@ INACTIVE_TEST_USERNAME = "inactive@example.com" INACTIVE_TEST_USER_ID = "54321" TEST_POLICY_UID = "66666" +_CREATE_EVENT_ID = "564564654566" +_MEMBERSHIP_EVENT_ID = "74533457745" TEST_PRESERVATION_POLICY_UID = "1010101010" MATTER_RESPONSE = """ { @@ -169,6 +173,42 @@ ] } """ +TEST_EVENT_PAGE = { + "legalHoldEvents": [ + { + "eventUid": "564564654566", + "eventType": "HoldCreated", + "eventDate": "2015-05-16T15:07:44.820Z", + "legalHoldUid": "88888", + "actorUserUid": "12345", + "actorUsername": "holdcreator@example.com", + "actorFirstName": "john", + "actorLastName": "doe", + "actorUserExtRef": None, + "actorEmail": "holdcreatorr@example.com", + }, + { + "eventUid": "74533457745", + "eventType": "MembershipCreated", + "eventDate": "2019-05-17T15:07:44.820Z", + "legalHoldUid": "88888", + "legalHoldMembershipUid": "645576514441664433", + "custodianUserUid": "12345", + "custodianUsername": "kim.jones@code42.com", + "custodianFirstName": "kim", + "custodianLastName": "jones", + "custodianUserExtRef": None, + "custodianEmail": "user@example.com", + "actorUserUid": "1234512345", + "actorUsername": "creator@example.com", + "actorFirstName": "john", + "actorLastName": "doe", + "actorUserExtRef": None, + "actorEmail": "user@example.com", + }, + ] +} +EMPTY_EVENTS_RESPONSE = """{"legalHoldEvents": []}""" EMPTY_MATTERS_RESPONSE = """{"legalHolds": []}""" ALL_MATTERS_RESPONSE = """{{"legalHolds": [{}]}}""".format(MATTER_RESPONSE) LEGAL_HOLD_COMMAND = "legal-hold" @@ -212,6 +252,15 @@ def active_and_inactive_legal_hold_memberships_response(mocker): return [_create_py42_response(mocker, ALL_ACTIVE_AND_INACTIVE_CUSTODIANS_RESPONSE)] +@pytest.fixture +def empty_events_response(mocker): + return _create_py42_response(mocker, EMPTY_EVENTS_RESPONSE) + + +def events_list_generator(): + yield TEST_EVENT_PAGE + + @pytest.fixture def get_user_id_success(cli_state): cli_state.sdk.users.get_by_username.return_value = { @@ -246,6 +295,11 @@ def check_matter_accessible_failure(cli_state, custom_error): ) +@pytest.fixture +def get_all_events_success(cli_state): + cli_state.sdk.legalhold.get_all_events.return_value = events_list_generator() + + @pytest.fixture def user_already_added_response(mocker): mock_response = mocker.MagicMock(spec=Response) @@ -575,6 +629,44 @@ def test_list_with_csv_format_returns_no_response_when_response_is_empty( assert "Matter ID,Name,Description,Creator,Creation Date" not in result.output +def test_search_events_shows_events_that_respect_type_filters( + runner, cli_state, get_all_events_success +): + + result = runner.invoke( + cli, + ["legal-hold", "search-events", "--event-type", "HoldCreated"], + obj=cli_state, + ) + + assert _CREATE_EVENT_ID in result.output + assert _MEMBERSHIP_EVENT_ID not in result.output + + +def test_search_events_with_csv_returns_no_events_when_response_is_empty( + runner, cli_state, get_all_events_success, empty_events_response +): + cli_state.sdk.legalhold.get_all_events.return_value = empty_events_response + result = runner.invoke(cli, ["legal-hold", "events", "-f", "csv"], obj=cli_state) + + assert ( + "actorEmail,actorUsername,actorLastName,actorUserUid,actorUserExtRef" + not in result.output + ) + + +def test_search_events_is_called_with_expected_begin_timestamp(runner, cli_state): + expected_timestamp = convert_datetime_to_timestamp( + datetime.datetime.strptime("2017-01-01", "%Y-%m-%d") + ) + command = ["legal-hold", "search-events", "--begin", "2017-01-01T00:00:00"] + runner.invoke(cli, command, obj=cli_state) + + cli_state.sdk.legalhold.get_all_events.assert_called_once_with( + None, expected_timestamp, None + ) + + @pytest.mark.parametrize( "command, error_msg", [ From 6790edfefa5d4f24087dd78a9b815fa6379714bb Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 20 Apr 2021 15:16:20 -0500 Subject: [PATCH 226/349] Bugfix/Win-Newlines-and-use-pager (#269) --- CHANGELOG.md | 17 +++++++++++++++-- setup.py | 2 +- src/code42cli/cmds/alert_rules.py | 3 ++- src/code42cli/cmds/auditlogs.py | 5 +---- src/code42cli/cmds/detectionlists/__init__.py | 6 +----- src/code42cli/cmds/devices.py | 7 ++++--- src/code42cli/cmds/legal_hold.py | 6 +----- src/code42cli/cmds/search/extraction.py | 6 +----- src/code42cli/output_formats.py | 18 +++++++++++------- tests/cmds/search/test_extraction.py | 2 +- tests/cmds/test_departing_employee.py | 4 ++-- tests/test_output_formats.py | 7 +++++-- 12 files changed, 45 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39cf5c2f8..6edec3fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Fixed + +- Bug where some CSV outputs on Windows would have an extra newline between the rows. + +- Issue where outputting or sending an alert or file-event with a timestamp without + decimals would error. + +### Changed + +- `code42 alert-rules list` now outputs via a pager when results contain more than 10 rules. + +- `code42 cases list` now outputs via a pager when results contain more than 10 cases. + ## 1.4.1 - 2021-04-15 ### Added @@ -18,8 +33,6 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `--end` filters based on an end timestamp. - `--event-type` filters based on a list of event types. -## 1.4.1 - 2021-04-15 - ### Fixed - Arguments/options that read data from files now attempt to autodetect file encodings. diff --git a/setup.py b/setup.py index a357a7619..7d586edde 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ "click>=7.1.1", "click_plugins>=1.1.1", "colorama>=0.4.3", - "c42eventextractor==0.4.0", + "c42eventextractor==0.4.1", "keyring==18.0.1", "keyrings.alt==3.2.0", "pandas>=1.1.3", diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index fc10fa460..3b1fec7f2 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -80,10 +80,11 @@ def remove_user(state, rule_id, username): @alert_rules.command("list") @format_option @sdk_options() -def list_alert_rules(state, format=None): +def list_alert_rules(state, format): """Fetch existing alert rules.""" formatter = OutputFormatter(format, _HEADER_KEYS_MAP) selected_rules = _get_all_rules_metadata(state.sdk) + if selected_rules: formatter.echo_formatted_list(selected_rules) diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index ebd66ede8..f68d97236 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -155,10 +155,7 @@ def search( if not events: click.echo("No results found.") return - elif len(events) > 10: - click.echo_via_pager(formatter.get_formatted_output(events)) - else: - formatter.echo_formatted_list(events) + formatter.echo_formatted_list(events) @audit_logs.command(cls=SendToCommand) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py index a332888fb..ddc039d40 100644 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ b/src/code42cli/cmds/detectionlists/__init__.py @@ -34,11 +34,7 @@ def list_employees(employee_generator, output_format, additional_header_items=No employee_list.append(employee) if employee_list: formatter = OutputFormatter(output_format, header) - if len(employee_list) > 10: - output = formatter.get_formatted_output(employee_list) - click.echo_via_pager(output) - else: - formatter.echo_formatted_list(employee_list) + formatter.echo_formatted_list(employee_list) else: click.echo("No users found.") diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 52e9af338..689c3a254 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -22,6 +22,7 @@ from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import DataFrameOutputFormatter +from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter @@ -154,11 +155,11 @@ def _change_device_name(sdk, guid, name): @devices.command() @device_guid_argument @sdk_options() -def show(state, device_guid, format=None): +def show(state, device_guid): """Print individual device details. Requires device GUID.""" - formatter = OutputFormatter(format, _device_info_keys_map()) - backup_set_formatter = OutputFormatter(format, _backup_set_keys_map()) + formatter = OutputFormatter(OutputFormat.TABLE, _device_info_keys_map()) + backup_set_formatter = OutputFormatter(OutputFormat.TABLE, _backup_set_keys_map()) device_info = _get_device_info(state.sdk, device_guid) formatter.echo_formatted_list([device_info]) backup_usage = device_info.get("backupUsage") diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index a7c3882b9..7f2f7837b 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -165,11 +165,7 @@ def search_events(state, matter_id, event_type, begin, end, format): events = _get_all_events(state.sdk, matter_id, begin, end) if event_type: events = [event for event in events if event["eventType"] == event_type] - if len(events) > 10: - output = formatter.get_formatted_output(events) - click.echo_via_pager(output) - else: - formatter.echo_formatted_list(events) + formatter.echo_formatted_list(events) @legal_hold.group(cls=OrderedGroup) diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index c55983b66..e15d4746f 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -101,11 +101,7 @@ def handle_response(response): events = _get_events(sdk, handlers, extractor._key, response) total_events = len(events) handlers.TOTAL_EVENTS += total_events - - if total_events > 10 or force_pager: - click.echo_via_pager(formatter.get_formatted_output(events)) - else: - formatter.echo_formatted_list(events) + formatter.echo_formatted_list(events, force_pager=force_pager) # To make sure the extractor records correct timestamp event when `CTRL-C` is pressed. if total_events: diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index d5faba156..b78911647 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -12,6 +12,7 @@ CEF_DEFAULT_PRODUCT_NAME = "Advanced Exfiltration Detection" CEF_DEFAULT_SEVERITY_LEVEL = "5" +OUTPUT_VIA_PAGER_THRESHOLD = 10 class JsonOutputFormat: @@ -66,12 +67,15 @@ def get_formatted_output(self, output): for item in output: yield self._format_output(item) - def echo_formatted_list(self, output_list): + def echo_formatted_list(self, output_list, force_pager=False): formatted_output = self.get_formatted_output(output_list) - for output in formatted_output: - click.echo(output, nl=False) - if self.output_format in [OutputFormat.TABLE]: - click.echo() + if len(output_list) > OUTPUT_VIA_PAGER_THRESHOLD or force_pager: + click.echo_via_pager(formatted_output) + else: + for output in formatted_output: + click.echo(output, nl=False) + if self.output_format in [OutputFormat.TABLE]: + click.echo() @property def _requires_list_output(self): @@ -124,7 +128,7 @@ def get_formatted_output(self, df, **kwargs): def echo_formatted_dataframe(self, df, **kwargs): str_output = self.get_formatted_output(df, **kwargs) - if len(df) <= 10: + if len(df) <= OUTPUT_VIA_PAGER_THRESHOLD: click.echo(str_output) else: click.echo_via_pager(str_output) @@ -135,7 +139,7 @@ def to_csv(output): if not output: return - string_io = io.StringIO() + string_io = io.StringIO(newline=None) fieldnames = list({k for d in output for k in d.keys()}) writer = csv.DictWriter(string_io, fieldnames=fieldnames) writer.writeheader() diff --git a/tests/cmds/search/test_extraction.py b/tests/cmds/search/test_extraction.py index c18b81186..312b331c1 100644 --- a/tests/cmds/search/test_extraction.py +++ b/tests/cmds/search/test_extraction.py @@ -64,7 +64,7 @@ def _get_timestamp_from_item(self, item): http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) py42_response = Py42Response(http_response) handlers.handle_response(py42_response) - formatter.echo_formatted_list.assert_called_once_with(events) + formatter.echo_formatted_list.assert_called_once_with(events, force_pager=False) def test_send_to_handlers_creates_handlers_that_pass_events_to_logger( diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 75da9c6e2..f16447c19 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -192,7 +192,7 @@ def test_add_departing_employee_when_user_does_not_exist_exits( def test_add_departing_employee_when_user_already_exits_with_correct_message( - mocker, runner, cli_state_with_user, user_already_added_error + runner, cli_state_with_user, user_already_added_error ): def add_user(user): raise user_already_added_error @@ -224,7 +224,7 @@ def test_remove_departing_employee_when_user_does_not_exist_exits( assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output -def test_add_bulk_users_calls_expected_py42_methods(runner, mocker, cli_state): +def test_add_bulk_users_calls_expected_py42_methods(runner, cli_state): de_add_user = thread_safe_side_effect() add_user_cloud_alias = thread_safe_side_effect() update_user_notes = thread_safe_side_effect() diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index dd37148d6..e03911cf9 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -821,11 +821,14 @@ def test_table_formatter_converts_to_expected_string(self): " string2 43 " ) - def test_echo_formatted_dataframe_uses_pager_when_gt_10_rows(self, mocker): + def test_echo_formatted_dataframe_uses_pager_when_len_rows_gt_threshold_const( + self, mocker + ): mock_echo = mocker.patch("click.echo") mock_pager = mocker.patch("click.echo_via_pager") formatter = DataFrameOutputFormatter(OutputFormat.TABLE) - big_df = DataFrame([{"column": val} for val in range(11)]) + rows_len = output_formats_module.OUTPUT_VIA_PAGER_THRESHOLD + 1 + big_df = DataFrame([{"column": val} for val in range(rows_len)]) small_df = DataFrame([{"column": val} for val in range(5)]) formatter.echo_formatted_dataframe(big_df) formatter.echo_formatted_dataframe(small_df) From c54c656f6a8c866899e1051c5ebba8b7e0518a25 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 22 Apr 2021 07:19:34 -0500 Subject: [PATCH 227/349] Bugfix/too many py42s (#271) --- CHANGELOG.md | 2 ++ src/code42cli/cmds/departing_employee.py | 21 ++++++++------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edec3fc1..94b37346e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Issue where outputting or sending an alert or file-event with a timestamp without decimals would error. +- A performance issue with the `code42 departing-employee bulk add` command. + ### Changed - `code42 alert-rules list` now outputs via a pager when results contain more than 10 rules. diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 902b2bd0c..ab696c570 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -66,8 +66,6 @@ def _list(state, format, filter): @sdk_options() def add(state, username, cloud_alias, departure_date, notes): """Add a user to the Departing Employees detection list.""" - if departure_date: - departure_date = departure_date.strftime(DATE_FORMAT) _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) @@ -102,8 +100,9 @@ def bulk(state): ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @sdk_options() -@click.pass_context -def bulk_add(ctx, state, csv_rows): +def bulk_add(state, csv_rows): + sdk = state.sdk # Force initialization of py42 to only happen once. + def handle_row(username, cloud_alias, departure_date, notes): if departure_date: try: @@ -111,17 +110,11 @@ def handle_row(username, cloud_alias, departure_date, notes): departure_date, None, None ) except click.exceptions.BadParameter: - message = "Invalid date {}, valid date format {}".format( - departure_date, DATE_FORMAT + message = ( + f"Invalid date {departure_date}, valid date format {DATE_FORMAT}." ) raise Code42CLIError(message) - ctx.invoke( - add, - username=username, - cloud_alias=cloud_alias, - departure_date=departure_date, - notes=notes, - ) + _add_departing_employee(sdk, username, cloud_alias, departure_date, notes) run_bulk_process( handle_row, @@ -155,6 +148,8 @@ def _get_departing_employees(sdk, filter): def _add_departing_employee(sdk, username, cloud_alias, departure_date, notes): + if departure_date: + departure_date = departure_date.strftime(DATE_FORMAT) user_id = get_user_id(sdk, username) sdk.detectionlists.departing_employee.add(user_id, departure_date) update_user(sdk, username, cloud_alias=cloud_alias, notes=notes) From 0dbed1e9d2a54821a16a3b648fb475594e0204ca Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 22 Apr 2021 13:03:02 -0500 Subject: [PATCH 228/349] Releaseprep (#272) --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b37346e..2ac3297d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.4.2 - 2021-04-22 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index bf2561596..daa50c7cf 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.4.1" +__version__ = "1.4.2" From e26da51db6467c6c7ea3caeba798f1114fca188d Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Thu, 22 Apr 2021 13:04:29 -0500 Subject: [PATCH 229/349] List users (#270) --- CHANGELOG.md | 7 +++ src/code42cli/cmds/users.py | 73 ++++++++++++++++++++++++ src/code42cli/main.py | 2 + tests/cmds/test_users.py | 110 ++++++++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 src/code42cli/cmds/users.py create mode 100644 tests/cmds/test_users.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac3297d8..a85833f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## 1.4.2 - 2021-04-22 +### Added + +- New command `code42 users list` with options: + - `--org-uid` filters on org membership. + - `--role-name` filters on users having a particular role. + - `--active` and `--inactive` filter on user status. + ### Fixed - Bug where some CSV outputs on Windows would have an extra newline between the rows. diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py new file mode 100644 index 000000000..4961fe59e --- /dev/null +++ b/src/code42cli/cmds/users.py @@ -0,0 +1,73 @@ +import click +from pandas import DataFrame + +from code42cli.click_ext.groups import OrderedGroup +from code42cli.click_ext.options import incompatible_with +from code42cli.errors import Code42CLIError +from code42cli.options import format_option +from code42cli.options import sdk_options +from code42cli.output_formats import DataFrameOutputFormatter + + +@click.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def users(state): + """Manage users within your Code42 environment.""" + pass + + +org_uid_option = click.option( + "--org-uid", + help="Limit users to only those in the organization you specify. Note that child orgs are included.", +) +role_name_option = click.option( + "--role-name", help="Limit results to only users having the specified role.", +) +active_option = click.option( + "--active", is_flag=True, help="Limits results to only active users.", default=None, +) +inactive_option = click.option( + "--inactive", + is_flag=True, + help="Limits results to only deactivated users.", + cls=incompatible_with("active"), +) + + +@users.command(name="list") +@org_uid_option +@role_name_option +@active_option +@inactive_option +@format_option +@sdk_options() +def list_users(state, org_uid, role_name, active, inactive, format): + """List users in your Code42 environment.""" + if inactive: + active = False + role_id = _get_role_id(state.sdk, role_name) if role_name else None + df = _get_users_dataframe(state.sdk, org_uid, role_id, active) + if df.empty: + click.echo("No results found.") + else: + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframe(df) + + +def _get_role_id(sdk, role_name): + try: + roles_dataframe = DataFrame.from_records( + sdk.users.get_available_roles().data, index="roleName" + ) + role_result = roles_dataframe.at[role_name, "roleId"] + return str(role_result) # extract the role ID from the series + except KeyError: + raise Code42CLIError(f"Role with name {role_name} not found.") + + +def _get_users_dataframe(sdk, org_uid, role_id, active): + users_generator = sdk.users.get_all(active=active, org_uid=org_uid, role_id=role_id) + users_list = [] + for page in users_generator: + users_list.extend(page["users"]) + return DataFrame.from_records(users_list) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index d4876241f..a462911f3 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -20,6 +20,7 @@ from code42cli.cmds.legal_hold import legal_hold from code42cli.cmds.profile import profile from code42cli.cmds.securitydata import security_data +from code42cli.cmds.users import users from code42cli.options import sdk_options BANNER = """\b @@ -80,5 +81,6 @@ def cli(state, python): cli.add_command(legal_hold) cli.add_command(profile) cli.add_command(devices) +cli.add_command(users) cli.add_command(audit_logs) cli.add_command(cases) diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py new file mode 100644 index 000000000..28d004a0a --- /dev/null +++ b/tests/cmds/test_users.py @@ -0,0 +1,110 @@ +import json + +import pytest +from py42.response import Py42Response +from requests import Response + +from code42cli.main import cli + +TEST_ROLE_RETURN_DATA = { + "data": [{"roleName": "Customer Cloud Admin", "roleId": "1234543"}] +} + +TEST_USERS_RESPONSE = { + "users": [ + { + "userId": 1234, + "userUid": "997962681513153325", + "status": "Active", + "username": "test_username@code42.com", + "creationDate": "2021-03-12T20:07:40.898Z", + "modificationDate": "2021-03-12T20:07:40.938Z", + } + ] +} + + +def _create_py42_response(mocker, text): + response = mocker.MagicMock(spec=Response) + response.text = text + response._content_consumed = mocker.MagicMock() + response.status_code = 200 + return Py42Response(response) + + +def get_all_users_generator(): + yield TEST_USERS_RESPONSE + + +@pytest.fixture +def get_available_roles_response(mocker): + return _create_py42_response(mocker, json.dumps(TEST_ROLE_RETURN_DATA)) + + +@pytest.fixture +def get_all_users_success(cli_state): + cli_state.sdk.users.get_all.return_value = get_all_users_generator() + + +@pytest.fixture +def get_available_roles_success(cli_state, get_available_roles_response): + cli_state.sdk.users.get_available_roles.return_value = get_available_roles_response + + +def test_list_outputs_appropriate_columns(runner, cli_state, get_all_users_success): + result = runner.invoke(cli, ["users", "list"], obj=cli_state) + assert "userId" in result.output + assert "userUid" in result.output + assert "status" in result.output + assert "username" in result.output + assert "creationDate" in result.output + assert "modificationDate" in result.output + + +def test_list_users_calls_users_get_all_with_expected_role_id( + runner, cli_state, get_available_roles_success, get_all_users_success +): + ROLE_NAME = "Customer Cloud Admin" + runner.invoke(cli, ["users", "list", "--role-name", ROLE_NAME], obj=cli_state) + cli_state.sdk.users.get_all.assert_called_once_with( + active=None, org_uid=None, role_id="1234543" + ) + + +def test_list_users_calls_get_all_users_with_correct_parameters( + runner, cli_state, get_all_users_success +): + org_uid = "TEST_ORG_UID" + runner.invoke( + cli, ["users", "list", "--org-uid", org_uid, "--active"], obj=cli_state + ) + cli_state.sdk.users.get_all.assert_called_once_with( + active=True, org_uid=org_uid, role_id=None + ) + + +def test_list_users_when_given_inactive_uses_active_equals_false( + runner, cli_state, get_available_roles_success, get_all_users_success +): + runner.invoke(cli, ["users", "list", "--inactive"], obj=cli_state) + cli_state.sdk.users.get_all.assert_called_once_with( + active=False, org_uid=None, role_id=None + ) + + +def test_list_users_when_given_active_and_inactive_raises_error( + runner, cli_state, get_available_roles_success, get_all_users_success +): + result = runner.invoke( + cli, ["users", "list", "--active", "--inactive"], obj=cli_state + ) + assert "Error: --inactive can't be used with: --active" in result.output + + +def test_list_users_when_given_excluding_active_and_inactive_uses_active_equals_none( + runner, cli_state, get_available_roles_success, get_all_users_success +): + runner.invoke(cli, ["users", "list"], obj=cli_state) + cli_state.sdk.users.get_all.assert_called_once_with( + active=None, org_uid=None, role_id=None + ) From 4998d02fe19f190975c624cfee20665f07398287 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 23 Apr 2021 14:40:37 -0500 Subject: [PATCH 230/349] Users fixes (#274) --- src/code42cli/cmds/users.py | 13 ++++++-- tests/cmds/test_users.py | 61 ++++++++++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 4961fe59e..b1f48f9c5 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -7,6 +7,7 @@ from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import DataFrameOutputFormatter +from code42cli.output_formats import OutputFormat @click.group(cls=OrderedGroup) @@ -46,7 +47,12 @@ def list_users(state, org_uid, role_name, active, inactive, format): if inactive: active = False role_id = _get_role_id(state.sdk, role_name) if role_name else None - df = _get_users_dataframe(state.sdk, org_uid, role_id, active) + columns = ( + ["userUid", "status", "username", "orgUid"] + if format == OutputFormat.TABLE + else None + ) + df = _get_users_dataframe(state.sdk, columns, org_uid, role_id, active) if df.empty: click.echo("No results found.") else: @@ -65,9 +71,10 @@ def _get_role_id(sdk, role_name): raise Code42CLIError(f"Role with name {role_name} not found.") -def _get_users_dataframe(sdk, org_uid, role_id, active): +def _get_users_dataframe(sdk, columns, org_uid, role_id, active): users_generator = sdk.users.get_all(active=active, org_uid=org_uid, role_id=role_id) users_list = [] for page in users_generator: users_list.extend(page["users"]) - return DataFrame.from_records(users_list) + + return DataFrame.from_records(users_list, columns=columns) diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 28d004a0a..634575cde 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -6,19 +6,29 @@ from code42cli.main import cli + TEST_ROLE_RETURN_DATA = { "data": [{"roleName": "Customer Cloud Admin", "roleId": "1234543"}] } - TEST_USERS_RESPONSE = { "users": [ { - "userId": 1234, - "userUid": "997962681513153325", + "firstName": "test", + "lastName": "username", + "orgId": 4321, + "orgUid": "44444444", + "orgName": "ORG_NAME", "status": "Active", - "username": "test_username@code42.com", + "notes": "This is a note.", + "active": True, + "blocked": False, "creationDate": "2021-03-12T20:07:40.898Z", "modificationDate": "2021-03-12T20:07:40.938Z", + "userId": 1234, + "username": "test.username@example.com", + "userUid": "911162111513111325", + "invited": False, + "quotaInBytes": 55555, } ] } @@ -51,14 +61,49 @@ def get_available_roles_success(cli_state, get_available_roles_response): cli_state.sdk.users.get_available_roles.return_value = get_available_roles_response -def test_list_outputs_appropriate_columns(runner, cli_state, get_all_users_success): - result = runner.invoke(cli, ["users", "list"], obj=cli_state) +def test_list_when_non_table_format_outputs_expected_columns( + runner, cli_state, get_all_users_success +): + result = runner.invoke(cli, ["users", "list", "-f", "CSV"], obj=cli_state) + assert "firstName" in result.output + assert "lastName" in result.output + assert "orgId" in result.output + assert "orgUid" in result.output + assert "orgName" in result.output + assert "status" in result.output + assert "notes" in result.output + assert "active" in result.output + assert "blocked" in result.output + assert "creationDate" in result.output + assert "modificationDate" in result.output assert "userId" in result.output + assert "username" in result.output assert "userUid" in result.output + assert "invited" in result.output + assert "quotaInBytes" in result.output + + +def test_list_when_table_format_outputs_expected_columns( + runner, cli_state, get_all_users_success +): + result = runner.invoke(cli, ["users", "list", "-f", "TABLE"], obj=cli_state) + assert "orgUid" in result.output assert "status" in result.output assert "username" in result.output - assert "creationDate" in result.output - assert "modificationDate" in result.output + assert "userUid" in result.output + + assert "firstName" not in result.output + assert "lastName" not in result.output + assert "orgId" not in result.output + assert "orgName" not in result.output + assert "notes" not in result.output + assert "active" not in result.output + assert "blocked" not in result.output + assert "creationDate" not in result.output + assert "modificationDate" not in result.output + assert "userId" not in result.output + assert "invited" not in result.output + assert "quotaInBytes" not in result.output def test_list_users_calls_users_get_all_with_expected_role_id( From 153a2a22867e250cdc4d1627e7b61c4cc2f189af Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 27 Apr 2021 15:25:17 -0500 Subject: [PATCH 231/349] echo no results found when searching lg events: (#276) --- src/code42cli/cmds/legal_hold.py | 5 ++++- tests/cmds/test_legal_hold.py | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 7f2f7837b..21eda14bb 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -165,7 +165,10 @@ def search_events(state, matter_id, event_type, begin, end, format): events = _get_all_events(state.sdk, matter_id, begin, end) if event_type: events = [event for event in events if event["eventType"] == event_type] - formatter.echo_formatted_list(events) + if events: + formatter.echo_formatted_list(events) + else: + click.echo("No results found.") @legal_hold.group(cls=OrderedGroup) diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index 13f6bc2cf..1b157e850 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -667,6 +667,13 @@ def test_search_events_is_called_with_expected_begin_timestamp(runner, cli_state ) +def test_search_events_when_no_results_outputs_no_results(runner, cli_state): + cli_state.sdk.legalhold.get_all_matters.return_value = empty_matters_response + command = ["legal-hold", "search-events"] + result = runner.invoke(cli, command, obj=cli_state) + assert "No results found." in result.output + + @pytest.mark.parametrize( "command, error_msg", [ @@ -697,7 +704,7 @@ def test_search_events_is_called_with_expected_begin_timestamp(runner, cli_state ), ], ) -def test_alert_rules_command_when_missing_required_parameters_returns_error( +def test_legal_hold_command_when_missing_required_parameters_returns_error( command, error_msg, runner, cli_state ): result = runner.invoke(cli, command.split(" "), obj=cli_state) From 1931d8cf20f7d52e1de675b723d9a2e2765b1ceb Mon Sep 17 00:00:00 2001 From: Kiran Chaudhary <61223509+kiran-chaudhary@users.noreply.github.com> Date: Thu, 29 Apr 2021 19:32:52 +0530 Subject: [PATCH 232/349] Feature/case bulk commands (#273) * added cases file-events bulk commands * fix style * add unit tests * Added changelog * change docs --- CHANGELOG.md | 11 +++++++ src/code42cli/cmds/cases.py | 60 +++++++++++++++++++++++++++++++++++++ tests/cmds/test_cases.py | 36 ++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a85833f6e..ec42992be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- `code42 cases file-events bulk` with sub-commands: + - `generate-template`: that creates the file template. And parameters: + - `cmd`: with options `add` and `remove`. + - `path` + - `add`: that takes a csv file with case number and event ID. + - `remove`: that takes a csv file with case number and event ID. + ## 1.4.2 - 2021-04-22 ### Added diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index aaf5d2e59..dc8629672 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -8,8 +8,11 @@ from py42.exceptions import Py42NotFoundError from py42.exceptions import Py42UpdateClosedCaseError +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process from code42cli.click_ext.groups import OrderedGroup from code42cli.errors import Code42CLIError +from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.options import set_begin_default_dict @@ -261,3 +264,60 @@ def remove(state, case_number, event_id): state.sdk.cases.file_events.delete(case_number, event_id) except Py42NotFoundError: raise Code42CLIError("Invalid case-number or event-id.") + + +@file_events.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def bulk(state): + """Tools for executing bulk case file-event actions.""" + pass + + +FILE_EVENTS_HEADERS = [ + "number", + "eventId", +] + +case_file_events_generate_template = generate_template_cmd_factory( + group_name="file_events", + commands_dict={"add": FILE_EVENTS_HEADERS, "remove": FILE_EVENTS_HEADERS}, +) +bulk.add_command(case_file_events_generate_template) + + +@bulk.command( + name="add", + help="Bulk associate file events to cases using a CSV file with " + "format: {}.".format(",".join(FILE_EVENTS_HEADERS)), +) +@read_csv_arg(headers=FILE_EVENTS_HEADERS) +@sdk_options() +def bulk_add(state, csv_rows): + sdk = state.sdk + + def handle_row(case_number, event_id): + sdk.cases.file_events.add(case_number, event_id) + + run_bulk_process( + handle_row, csv_rows, progress_label="Associating file events to cases:", + ) + + +@bulk.command( + name="remove", + help="Bulk remove the file event association from cases using a CSV file with " + "format: {}.".format(",".join(FILE_EVENTS_HEADERS)), +) +@read_csv_arg(headers=FILE_EVENTS_HEADERS) +@sdk_options() +def bulk_remove(state, csv_rows): + sdk = state.sdk + + def handle_row(case_number, event_id): + sdk.cases.file_events.delete(case_number, event_id) + + run_bulk_process( + handle_row, + csv_rows, + progress_label="Removing the file event association from cases:", + ) diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index 9157f68ef..25392b3fd 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -464,3 +464,39 @@ def test_fileevents_when_event_id_is_already_associated_with_case_py42_raises_ex ) cli_state.sdk.cases.file_events.add.assert_called_once_with(1, "1") assert "Event is already associated to the case." in result.output + + +def test_add_bulk_file_events_to_cases_uses_expected_arguments( + runner, mocker, cli_state_with_user +): + bulk_processor = mocker.patch("code42cli.cmds.cases.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_add.csv", "w") as csv: + csv.writelines(["number,eventId\n", "1,abc\n", "2,pqr\n"]) + runner.invoke( + cli, + ["cases", "file-events", "bulk", "add", "test_add.csv"], + obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"number": "1", "eventId": "abc"}, + {"number": "2", "eventId": "pqr"}, + ] + + +def test_remove_bulk_file_events_from_cases_uses_expected_arguments( + runner, mocker, cli_state_with_user +): + bulk_processor = mocker.patch("code42cli.cmds.cases.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines(["number,eventId\n", "1,abc\n", "2,pqr\n"]) + runner.invoke( + cli, + ["cases", "file-events", "bulk", "remove", "test_remove.csv"], + obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"number": "1", "eventId": "abc"}, + {"number": "2", "eventId": "pqr"}, + ] From 745c940967940e0e2dae763775c838a5df83ccb0 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 29 Apr 2021 10:30:10 -0500 Subject: [PATCH 233/349] Feature/bulk alert update (#275) * wip * Add commands * CL * Adjust help message for bulk gen templ * Make help text generic * conform help gen messag * show command inc note * bump py42 * optimize * WIP * test show * Test update state * Finish testing * Correct CL * add to cl * Style * optionally include observations * rm dup line * drop s --- CHANGELOG.md | 16 +++ setup.py | 2 +- src/code42cli/cmds/alerts.py | 120 ++++++++++++++-- tests/cmds/test_alerts.py | 259 ++++++++++++++++++++++++++++++++++- 4 files changed, 384 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec42992be..ad8421992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,22 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- New command `code42 alerts show` that displays information about a single alert. + +- New command `code42 alerts update` that can update an alert's state or note. + +- New command `code42 alerts bulk generate-tempate` for generating CSV templates for bulk + commands. + +- New command `code42 alerts bulk update` for bulk updating alerts. + +### Changed + +- `code42 alerts search` now includes the alert ID in its table output. + +- `code42 alerts search` table output now refers to the alert state as `state` instead of + `status`. + - `code42 cases file-events bulk` with sub-commands: - `generate-template`: that creates the file template. And parameters: - `cmd`: with options `add` and `remove`. diff --git a/setup.py b/setup.py index 7d586edde..df42fb67b 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ "keyring==18.0.1", "keyrings.alt==3.2.0", "pandas>=1.1.3", - "py42>=1.11.1", + "py42>=1.14.1", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 9b2bc776d..feb72f75e 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -1,23 +1,29 @@ import click import py42.sdk.queries.alerts.filters as f from c42eventextractor.extractors import AlertExtractor +from py42.exceptions import Py42NotFoundError from py42.sdk.queries.alerts.filters import AlertState from py42.sdk.queries.alerts.filters import RuleType from py42.sdk.queries.alerts.filters import Severity +from py42.util import format_dict -import code42cli.click_ext.groups import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt import code42cli.errors as errors import code42cli.options as opt +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process +from code42cli.click_ext.groups import OrderedGroup from code42cli.cmds.search import SendToCommand from code42cli.cmds.search.cursor_store import AlertCursorStore from code42cli.cmds.search.extraction import handle_no_events from code42cli.cmds.search.options import server_options from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import limit_date_range +from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.output_formats import JsonOutputFormat +from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter @@ -39,7 +45,7 @@ callback=searchopt.is_in_filter(f.Severity), help="Filter alerts by severity. Defaults to returning all severities.", ) -state_option = click.option( +filter_state_option = click.option( "--state", multiple=True, type=click.Choice(AlertState.choices()), @@ -134,14 +140,22 @@ help="The output format of the result. Defaults to json format.", default=JsonOutputFormat.RAW, ) +alert_id_arg = click.argument("alert-id") +note_option = click.option("--note", help="A note to attach to the alert.") +update_state_option = click.option( + "--state", + help="The state to give to the alert.", + type=click.Choice(AlertState.choices()), +) -def _get_search_default_header(): +def _get_default_output_header(): return { + "id": "Id", "name": "RuleName", "actor": "Username", "createdAt": "ObservedDate", - "state": "Status", + "state": "State", "severity": "Severity", "description": "Description", } @@ -155,7 +169,7 @@ def search_options(f): return f -def alert_options(f): +def filter_options(f): f = actor_option(f) f = actor_contains_option(f) f = exclude_actor_option(f) @@ -168,11 +182,11 @@ def alert_options(f): f = exclude_rule_type_option(f) f = description_option(f) f = severity_option(f) - f = state_option(f) + f = filter_state_option(f) return f -@click.group(cls=code42cli.click_ext.groups.OrderedGroup) +@click.group(cls=OrderedGroup) @opt.sdk_options(hidden=True) def alerts(state): """Get and send alert data.""" @@ -203,7 +217,7 @@ def _call_extractor( @alerts.command() -@alert_options +@filter_options @search_options @click.option( "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible @@ -225,11 +239,11 @@ def search( use_checkpoint, or_query, include_all, - **kwargs + **kwargs, ): """Search for alerts.""" output_header = ext.try_get_default_header( - include_all, _get_search_default_header(), format + include_all, _get_default_output_header(), format ) formatter = OutputFormatter(format, output_header) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None @@ -246,7 +260,7 @@ def search( @alerts.command(cls=SendToCommand) -@alert_options +@filter_options @search_options @click.option( "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible @@ -283,3 +297,87 @@ def _get_alert_extractor(sdk, handlers): def _get_alert_cursor_store(profile_name): return AlertCursorStore(profile_name) + + +@alerts.command() +@opt.sdk_options() +@alert_id_arg +@click.option( + "--include-observations", is_flag=True, help="View observations of the alert." +) +def show(state, alert_id, include_observations): + """Display the details of a single alert.""" + formatter = OutputFormatter(OutputFormat.TABLE, _get_default_output_header()) + + try: + response = state.sdk.alerts.get_details(alert_id) + except Py42NotFoundError: + raise errors.Code42CLIError(f"No alert found with ID '{alert_id}'.") + + alert = response["alerts"][0] + formatter.echo_formatted_list([alert]) + + # Show note details + note = alert.get("note") + if note: + click.echo("\nNote:\n") + click.echo(format_dict(note)) + + if include_observations: + observations = alert.get("observations") + if observations: + click.echo("\nObservations:\n") + click.echo(format_dict(observations)) + else: + click.echo("\nNo observations found.") + + +@alerts.command() +@opt.sdk_options() +@alert_id_arg +@update_state_option +@note_option +def update(cli_state, alert_id, state, note): + """Update alert information.""" + _update_alert(cli_state.sdk, alert_id, state, note) + + +@alerts.group(cls=OrderedGroup) +@opt.sdk_options(hidden=True) +def bulk(state): + """Tools for executing bulk alert actions.""" + pass + + +UPDATE_ALERT_CSV_HEADERS = ["id", "state", "note"] +update_alerts_generate_template = generate_template_cmd_factory( + group_name=ALERTS_KEYWORD, + commands_dict={"update": UPDATE_ALERT_CSV_HEADERS}, + help_message="Generate the CSV template needed for bulk alert commands.", +) +bulk.add_command(update_alerts_generate_template) + + +@bulk.command( + name="update", + help=f"Bulk update alerts using a CSV file with format: {','.join(UPDATE_ALERT_CSV_HEADERS)}", +) +@opt.sdk_options() +@read_csv_arg(headers=UPDATE_ALERT_CSV_HEADERS) +def bulk_update(cli_state, csv_rows): + """Bulk update alerts.""" + sdk = cli_state.sdk + + def handle_row(id, state, note): + _update_alert(sdk, id, state, note) + + run_bulk_process( + handle_row, csv_rows, progress_label="Updating alerts:", + ) + + +def _update_alert(sdk, alert_id, alert_state, note): + if alert_state: + sdk.alerts.update_state(alert_state, [alert_id], note=note) + elif note: + sdk.alerts.update_note(alert_id, note) diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 6946dae5d..a6938556b 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -4,6 +4,10 @@ import py42.sdk.queries.alerts.filters as f import pytest from c42eventextractor.extractors import AlertExtractor +from py42.exceptions import Py42NotFoundError +from py42.response import Py42Response +from py42.sdk.queries.alerts.filters import AlertState +from requests import Response from tests.cmds.conftest import filter_term_is_in_call_args from tests.cmds.conftest import get_filter_value_from_json from tests.cmds.conftest import get_mark_for_search_and_send_to @@ -203,6 +207,94 @@ ("--state", "OPEN"), ], ) +ALERT_DETAILS_FULL_RESPONSE = { + "type$": "ALERT_DETAILS_RESPONSE", + "alerts": [ + { + "type$": "ALERT_DETAILS", + "tenantId": "11111111-2222-3333-4444-55559a126666", + "type": "FED_ENDPOINT_EXFILTRATION", + "name": "Some Burp Suite Test Rule", + "description": "Some Burp Rule", + "actor": "neilwin0415@code42.com", + "actorId": "1002844444570300000", + "target": "N/A", + "severity": "HIGH", + "ruleId": "e9bfa082-4541-4432-aacd-d8b2ca074762", + "ruleSource": "Alerting", + "id": "TEST-ALERT-ID-123", + "createdAt": "2021-04-23T21:18:59.2032940Z", + "state": "PENDING", + "stateLastModifiedBy": "test@example.com", + "stateLastModifiedAt": "2021-04-26T12:37:30.4605390Z", + "observations": [ + { + "type$": "OBSERVATION", + "id": "f561e556-a746-4db0-b99b-71546adf57c4", + "observedAt": "2021-04-23T21:10:00.0000000Z", + "type": "FedEndpointExfiltration", + "data": { + "type$": "OBSERVED_ENDPOINT_ACTIVITY", + "id": "f561e556-a746-4db0-b99b-71546adf57c4", + "sources": ["Endpoint"], + "exposureTypes": ["ApplicationRead"], + "firstActivityAt": "2021-04-23T21:10:00.0000000Z", + "lastActivityAt": "2021-04-23T21:15:00.0000000Z", + "fileCount": 1, + "totalFileSize": 8326, + "fileCategories": [ + { + "type$": "OBSERVED_FILE_CATEGORY", + "category": "Image", + "fileCount": 1, + "totalFileSize": 8326, + "isSignificant": False, + } + ], + "files": [ + { + "type$": "OBSERVED_FILE", + "eventId": "0_c4e43418-07d9-4a9f-a138-29f39a124d33_1002847122023325984_4b6d298c-8660-4cb8-b6d1-61d09a5c69ba_0", + "path": "C:\\Users\\Test Testerson\\Downloads", + "name": "mad cat - Copy.jpg", + "category": "Image", + "size": 8326, + } + ], + "syncToServices": [], + "sendingIpAddresses": ["174.20.92.47"], + "appReadDetails": [ + { + "type$": "APP_READ_DETAILS", + "tabTitles": [ + "file.example.com - Super simple file sharing - Google Chrome" + ], + "tabUrl": "https://www.file.example.com/", + "tabInfos": [ + { + "type$": "TAB_INFO", + "tabUrl": "https://www.file.example.com/", + "tabTitle": "example - Super simple file sharing - Google Chrome", + } + ], + "destinationCategory": "Uncategorized", + "destinationName": "Uncategorized", + "processName": "\\Device\\HarddiskVolume3\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + } + ], + }, + } + ], + "note": { + "type$": "NOTE", + "id": "72f8cd62-5cb8-4896-947d-f07e17053eaf", + "lastModifiedAt": "2021-04-26T12:37:30.4987600Z", + "lastModifiedBy": "test@example.com", + "message": "TEST-NOTE-CLI-UNIT-TESTS", + }, + } + ], +} search_and_send_to_test = get_mark_for_search_and_send_to("alerts") @@ -252,6 +344,13 @@ def send_to_logger_factory(mocker): return mocker.patch("code42cli.cmds.search._try_get_logger_for_server") +@pytest.fixture +def full_alert_details_response(mocker): + response = mocker.MagicMock(spec=Response) + response.text = json.dumps(ALERT_DETAILS_FULL_RESPONSE) + return Py42Response(response) + + @search_and_send_to_test def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_expected_query( cli_state, alert_extractor, runner, command @@ -496,7 +595,6 @@ def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_check def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( cli_state, alert_extractor, alert_cursor_with_checkpoint, runner, command ): - result = runner.invoke( cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, ) @@ -864,3 +962,162 @@ def test_get_alert_details_sorts_results_by_date(sdk): sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT results = extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) assert results == SORTED_ALERT_DETAILS + + +def test_show_outputs_expected_headers(cli_state, runner, full_alert_details_response): + cli_state.sdk.alerts.get_details.return_value = full_alert_details_response + result = runner.invoke(cli, ["alerts", "show", "TEST-ALERT-ID"], obj=cli_state) + assert "Id" in result.output + assert "RuleName" in result.output + assert "Username" in result.output + assert "ObservedDate" in result.output + assert "State" in result.output + assert "Severity" in result.output + assert "Description" in result.output + + +def test_show_outputs_expected_values(cli_state, runner, full_alert_details_response): + cli_state.sdk.alerts.get_details.return_value = full_alert_details_response + result = runner.invoke(cli, ["alerts", "show", "TEST-ALERT-ID"], obj=cli_state) + # Values found in ALERT_DETAILS_FULL_RESPONSE. + assert "TEST-ALERT-ID-123" in result.output + assert "Some Burp Suite Test Rule" in result.output + assert "neilwin0415@code42.com" in result.output + assert "2021-04-23T21:18:59.2032940Z" in result.output + assert "PENDING" in result.output + assert "HIGH" in result.output + assert "Some Burp Rule" in result.output + + +def test_show_when_alert_has_note_includes_note( + cli_state, runner, full_alert_details_response +): + cli_state.sdk.alerts.get_details.return_value = full_alert_details_response + result = runner.invoke(cli, ["alerts", "show", "TEST-ALERT-ID"], obj=cli_state) + # Note is included in `full_alert_details_response` initially. + assert "Note" in result.output + assert "TEST-NOTE-CLI-UNIT-TESTS" in result.output + + +def test_show_when_alert_has_no_note_excludes_note( + mocker, cli_state, runner, full_alert_details_response +): + response = mocker.MagicMock(spec=Response) + sans_note_text = dict(ALERT_DETAILS_FULL_RESPONSE) + sans_note_text["alerts"][0]["note"] = None + response.text = json.dumps(sans_note_text) + cli_state.sdk.alerts.get_details.return_value = Py42Response(response) + result = runner.invoke(cli, ["alerts", "show", "TEST-ALERT-ID"], obj=cli_state) + # Note is included in `full_alert_details_response` initially. + assert "Note" not in result.output + + +def test_show_when_alert_not_found_output_expected_error_message( + cli_state, runner, custom_error +): + cli_state.sdk.alerts.get_details.side_effect = Py42NotFoundError(custom_error) + result = runner.invoke(cli, ["alerts", "show", "TEST-ALERT-ID"], obj=cli_state) + assert "No alert found with ID 'TEST-ALERT-ID'." in result.output + + +def test_show_when_alert_has_observations_and_includes_observations_outputs_observations( + cli_state, runner, full_alert_details_response +): + cli_state.sdk.alerts.get_details.return_value = full_alert_details_response + result = runner.invoke( + cli, + ["alerts", "show", "TEST-ALERT-ID", "--include-observations"], + obj=cli_state, + ) + assert "Observations:" in result.output + assert "OBSERVATION" in result.output + assert "f561e556-a746-4db0-b99b-71546adf57c4" in result.output + assert "observedAt" in result.output + assert "FedEndpointExfiltration" in result.output + + +def test_show_when_alert_has_observations_and_excludes_observations_does_not_output_observations( + cli_state, runner, full_alert_details_response +): + cli_state.sdk.alerts.get_details.return_value = full_alert_details_response + result = runner.invoke(cli, ["alerts", "show", "TEST-ALERT-ID"], obj=cli_state) + assert "Observations:" not in result.output + + +def test_show_when_alert_does_not_have_observations_and_includes_observations_outputs_no_observations( + mocker, cli_state, runner +): + response = mocker.MagicMock(spec=Response) + response_text = dict(ALERT_DETAILS_FULL_RESPONSE) + response_text["alerts"][0]["observations"] = None + response.text = json.dumps(response_text) + cli_state.sdk.alerts.get_details.return_value = Py42Response(response) + result = runner.invoke( + cli, + ["alerts", "show", "TEST-ALERT-ID", "--include-observations"], + obj=cli_state, + ) + assert "No observations found" in result.output + assert "Observations:" not in result.output + assert "FedEndpointExfiltration" not in result.output + + +def test_update_when_given_state_calls_py42_update_state(cli_state, runner): + runner.invoke( + cli, + ["alerts", "update", "TEST-ALERT-ID", "--state", AlertState.PENDING], + obj=cli_state, + ) + cli_state.sdk.alerts.update_state.assert_called_once_with( + AlertState.PENDING, ["TEST-ALERT-ID"], note=None + ) + + +def test_update_when_given_state_and_note_calls_py42_update_state_and_includes_note( + cli_state, runner +): + runner.invoke( + cli, + [ + "alerts", + "update", + "TEST-ALERT-ID", + "--state", + AlertState.PENDING, + "--note", + "test-note", + ], + obj=cli_state, + ) + cli_state.sdk.alerts.update_state.assert_called_once_with( + AlertState.PENDING, ["TEST-ALERT-ID"], note="test-note" + ) + + +def test_update_when_given_note_and_not_state_calls_py42_update_note(cli_state, runner): + runner.invoke( + cli, + ["alerts", "update", "TEST-ALERT-ID", "--note", "test-note"], + obj=cli_state, + ) + cli_state.sdk.alerts.update_note.assert_called_once_with( + "TEST-ALERT-ID", "test-note" + ) + + +def test_bulk_update_uses_expected_arguments(runner, mocker, cli_state_with_user): + bulk_processor = mocker.patch("code42cli.cmds.alerts.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_update.csv", "w") as csv: + csv.writelines( + ["id,state,note\n", "1,PENDING,note1\n", "2,IN_PROGRESS,note2\n"] + ) + runner.invoke( + cli, + ["alerts", "bulk", "update", "test_update.csv"], + obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"id": "1", "state": "PENDING", "note": "note1"}, + {"id": "2", "state": "IN_PROGRESS", "note": "note2"}, + ] From 9e0505a5f76807650fa626d07438f3c9c1aa3168 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Mon, 3 May 2021 06:49:25 -0500 Subject: [PATCH 234/349] Bugfix/cases bulk bad handler args (#278) --- src/code42cli/cmds/cases.py | 14 +++++++------- tests/cmds/test_cases.py | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index dc8629672..be415d5af 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -275,7 +275,7 @@ def bulk(state): FILE_EVENTS_HEADERS = [ "number", - "eventId", + "event_id", ] case_file_events_generate_template = generate_template_cmd_factory( @@ -288,15 +288,15 @@ def bulk(state): @bulk.command( name="add", help="Bulk associate file events to cases using a CSV file with " - "format: {}.".format(",".join(FILE_EVENTS_HEADERS)), + f"format: {','.join(FILE_EVENTS_HEADERS)}.", ) @read_csv_arg(headers=FILE_EVENTS_HEADERS) @sdk_options() def bulk_add(state, csv_rows): sdk = state.sdk - def handle_row(case_number, event_id): - sdk.cases.file_events.add(case_number, event_id) + def handle_row(number, event_id): + sdk.cases.file_events.add(number, event_id) run_bulk_process( handle_row, csv_rows, progress_label="Associating file events to cases:", @@ -306,15 +306,15 @@ def handle_row(case_number, event_id): @bulk.command( name="remove", help="Bulk remove the file event association from cases using a CSV file with " - "format: {}.".format(",".join(FILE_EVENTS_HEADERS)), + f"format: {','.join(FILE_EVENTS_HEADERS)}.", ) @read_csv_arg(headers=FILE_EVENTS_HEADERS) @sdk_options() def bulk_remove(state, csv_rows): sdk = state.sdk - def handle_row(case_number, event_id): - sdk.cases.file_events.delete(case_number, event_id) + def handle_row(number, event_id): + sdk.cases.file_events.delete(number, event_id) run_bulk_process( handle_row, diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index 25392b3fd..ee646ac8d 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -472,15 +472,15 @@ def test_add_bulk_file_events_to_cases_uses_expected_arguments( bulk_processor = mocker.patch("code42cli.cmds.cases.run_bulk_process") with runner.isolated_filesystem(): with open("test_add.csv", "w") as csv: - csv.writelines(["number,eventId\n", "1,abc\n", "2,pqr\n"]) + csv.writelines(["number,event_id\n", "1,abc\n", "2,pqr\n"]) runner.invoke( cli, ["cases", "file-events", "bulk", "add", "test_add.csv"], obj=cli_state_with_user, ) assert bulk_processor.call_args[0][1] == [ - {"number": "1", "eventId": "abc"}, - {"number": "2", "eventId": "pqr"}, + {"number": "1", "event_id": "abc"}, + {"number": "2", "event_id": "pqr"}, ] @@ -490,13 +490,13 @@ def test_remove_bulk_file_events_from_cases_uses_expected_arguments( bulk_processor = mocker.patch("code42cli.cmds.cases.run_bulk_process") with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: - csv.writelines(["number,eventId\n", "1,abc\n", "2,pqr\n"]) + csv.writelines(["number,event_id\n", "1,abc\n", "2,pqr\n"]) runner.invoke( cli, ["cases", "file-events", "bulk", "remove", "test_remove.csv"], obj=cli_state_with_user, ) assert bulk_processor.call_args[0][1] == [ - {"number": "1", "eventId": "abc"}, - {"number": "2", "eventId": "pqr"}, + {"number": "1", "event_id": "abc"}, + {"number": "2", "event_id": "pqr"}, ] From a12399cd04b4313716f625fc23cf66261bd81421 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 5 May 2021 12:56:58 -0500 Subject: [PATCH 235/349] Release prep (#279) --- CHANGELOG.md | 18 ++++++++++-------- src/code42cli/__version__.py | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8421992..be8319b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.5.0 - 2021-05-05 ### Added @@ -21,6 +21,15 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - New command `code42 alerts bulk update` for bulk updating alerts. +- New command `code42 cases file-events bulk generate-template` creates the template CSV + file for the given command arg. + +- New command `code42 cases file-events bulk add` that takes a CSV file with case number + and event ID. + +- New command `code42 cases file-events bulk remove` that takes a CSV file with case + number and event ID. + ### Changed - `code42 alerts search` now includes the alert ID in its table output. @@ -28,13 +37,6 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 alerts search` table output now refers to the alert state as `state` instead of `status`. -- `code42 cases file-events bulk` with sub-commands: - - `generate-template`: that creates the file template. And parameters: - - `cmd`: with options `add` and `remove`. - - `path` - - `add`: that takes a csv file with case number and event ID. - - `remove`: that takes a csv file with case number and event ID. - ## 1.4.2 - 2021-04-22 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index daa50c7cf..5b6018861 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.4.2" +__version__ = "1.5.0" From 783d756771d8fd5298f89281e33a87b09c563d85 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 6 May 2021 13:20:20 -0500 Subject: [PATCH 236/349] Add missing "l" to the word "template" (#280) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be8319b84..48e20ced2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - New command `code42 alerts update` that can update an alert's state or note. -- New command `code42 alerts bulk generate-tempate` for generating CSV templates for bulk +- New command `code42 alerts bulk generate-template` for generating CSV templates for bulk commands. - New command `code42 alerts bulk update` for bulk updating alerts. From f6fa8ea105a98bfa0662dc453fdc7e8f37e9ad30 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 7 May 2021 13:22:18 -0500 Subject: [PATCH 237/349] bump py42 (#281) --- CHANGELOG.md | 7 +++++++ setup.py | 2 +- src/code42cli/__version__.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e20ced2..7d977335f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.5.1 - 2021-05-07 + +### Fixed + +- Issue where the `--role-name` option on the command `code42 users list` caused the + CLI to call a deprecated method. + ## 1.5.0 - 2021-05-05 ### Added diff --git a/setup.py b/setup.py index df42fb67b..a19f07e3b 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ "keyring==18.0.1", "keyrings.alt==3.2.0", "pandas>=1.1.3", - "py42>=1.14.1", + "py42>=1.14.2", ], extras_require={ "dev": [ diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 5b6018861..0f228f258 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.5.0" +__version__ = "1.5.1" From b7bc9bbd5e06942182c07ae0c7fa1001ceaad2e4 Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Wed, 12 May 2021 09:56:17 -0500 Subject: [PATCH 238/349] pin click version to fix failing tests (#282) --- CHANGELOG.md | 4 +++- setup.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d977335f..530acb99d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## 1.5.1 - 2021-05-07 +## 1.5.1 - 2021-05-12 ### Fixed +- Issue where some error messages stopped displaying in the same way that they did in prior versions. + - Issue where the `--role-name` option on the command `code42 users list` caused the CLI to call a deprecated method. diff --git a/setup.py b/setup.py index a19f07e3b..7fa8995b6 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ zip_safe=False, python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4", install_requires=[ - "click>=7.1.1", + "click>=7.1.1, <8", "click_plugins>=1.1.1", "colorama>=0.4.3", "c42eventextractor==0.4.1", From 95195df84921ba116d218b17b8b4e8c4c5991c4a Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 18 May 2021 08:14:58 -0500 Subject: [PATCH 239/349] Feature/2fa (#277) * impl * tests * Changelog and user guides * style * rm unneeded error handling * unused import * rm comma * default totp=None on validate_connection * rm comma * use example.com --- CHANGELOG.md | 2 ++ docs/userguides/gettingstarted.md | 8 ++++- docs/userguides/profile.md | 6 ++++ src/code42cli/cmds/profile.py | 5 +++ src/code42cli/options.py | 21 ++++++++++++- src/code42cli/sdk_client.py | 20 ++++++++---- tests/cmds/test_profile.py | 26 ++++++++++++++++ tests/test_sdk_client.py | 51 +++++++++++++++++++++++++++++-- 8 files changed, 129 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 530acb99d..fd8b7b011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- Support for users that require multi-factor authentication. + - New command `code42 alerts show` that displays information about a single alert. - New command `code42 alerts update` that can update an alert's state or note. diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index 26441e132..58925bac4 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -72,10 +72,16 @@ python3 -m pip install code42cli --upgrade .. important:: The Code42 CLI currently only supports token-based authentication. ``` -Create a user in Code42 to authenticate (basic authentication) and access data via the CLI. The CLI returns data based on the roles assigned to this user. To ensure that the user's rights are not too permissive, create a user with the lowest level of privilege necessary. See our [Role assignment use cases](https://support.code42.com/Administrator/Cloud/Monitoring_and_managing/Role_assignment_use_cases) for information on recommended roles. We recommend you test to confirm that the user can access the right data. +Create a user in Code42 to authenticate (basic authentication) and access data via the CLI. The CLI returns data based +on the roles assigned to this user. To ensure that the user's rights are not too permissive, create a user with the lowest +level of privilege necessary. See our [Role assignment use cases](https://support.code42.com/Administrator/Cloud/Monitoring_and_managing/Role_assignment_use_cases) +for information on recommended roles. We recommend you test to confirm that the user can access the right data. If you choose not to store your password in the CLI, you must enter it for each command that requires a connection. +The Code42 CLI supports local accounts with MFA (multi-factor authentication) enabled. The Time-based One-Time +Password (TOTP) must be provided at every invocation of the CLI, either via the `--totp` option or when prompted. + The Code42 CLI currently does **not** support SSO login providers or any other identity providers such as Active Directory or Okta. diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md index e19a3b6ee..c6cd5cb49 100644 --- a/docs/userguides/profile.md +++ b/docs/userguides/profile.md @@ -33,3 +33,9 @@ To see all your profiles, do: ```bash code42 profile list ``` + +## Profiles with Multi-Factor Authentication + +If your Code42 user account requires multi-factor authentication, the token is not required to create your profile but +will be required for any subsequent CLI commands. The MFA token can either be passed in with the `--totp` option, or if +not passed you will be prompted to enter it before the command executes. diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index a4772c77d..d4ee18927 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -3,6 +3,7 @@ import click from click import echo from click import secho +from py42.exceptions import Py42MFARequiredError import code42cli.profile as cliprofile from code42cli.errors import Code42CLIError @@ -193,6 +194,10 @@ def _set_pw(profile_name, password): c42profile = cliprofile.get_profile(profile_name) try: validate_connection(c42profile.authority_url, c42profile.username, password) + except Py42MFARequiredError: + echo( + "Multi-factor account detected. `--totp ` option will be required for all code42 invocations." + ) except Exception: secho("Password not stored!", bold=True) raise diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 5f4d6c739..ffedfaf82 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -41,6 +41,7 @@ def __init__(self): self._profile = get_profile() except Code42CLIError: self._profile = None + self.totp = None self.debug = False self._sdk = None self.search_filters = [] @@ -59,7 +60,7 @@ def profile(self, value): @property def sdk(self): if self._sdk is None: - self._sdk = create_sdk(self.profile, self.debug) + self._sdk = create_sdk(self.profile, self.debug, self.totp) return self._sdk def set_assume_yes(self, param): @@ -81,6 +82,12 @@ def set_debug(ctx, param, value): ctx.ensure_object(CLIState).debug = value +def set_totp(ctx, param, value): + """Sets TOTP token on global state object for multi-factor authentication.""" + if value: + ctx.ensure_object(CLIState).totp = value + + def profile_option(hidden=False): opt = click.option( "--profile", @@ -105,12 +112,24 @@ def debug_option(hidden=False): return opt +def totp_option(hidden=False): + opt = click.option( + "--totp", + expose_value=False, + callback=set_totp, + hidden=hidden, + help="TOTP token for multi-factor authentication.", + ) + return opt + + pass_state = click.make_pass_decorator(CLIState, ensure=True) def sdk_options(hidden=False): def decorator(f): f = profile_option(hidden)(f) + f = totp_option(hidden)(f) f = debug_option(hidden)(f) f = pass_state(f) return f diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index 85888b69a..54c934f23 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -2,7 +2,9 @@ import py42.settings import py42.settings.debug as debug import requests +from click import prompt from click import secho +from py42.exceptions import Py42MFARequiredError from py42.exceptions import Py42UnauthorizedError from requests.exceptions import ConnectionError @@ -15,7 +17,7 @@ logger = get_main_cli_logger() -def create_sdk(profile, is_debug_mode): +def create_sdk(profile, is_debug_mode, totp=None): if is_debug_mode: py42.settings.debug.level = debug.DEBUG if profile.ignore_ssl_errors == "True": @@ -30,18 +32,24 @@ def create_sdk(profile, is_debug_mode): ) py42.settings.verify_ssl_certs = False password = profile.get_password() - return validate_connection(profile.authority_url, profile.username, password) + return validate_connection(profile.authority_url, profile.username, password, totp) -def validate_connection(authority_url, username, password): +def validate_connection(authority_url, username, password, totp=None): try: - return py42.sdk.from_local_account(authority_url, username, password) + return py42.sdk.from_local_account(authority_url, username, password, totp=totp) except ConnectionError as err: logger.log_error(str(err)) - raise LoggedCLIError("Problem connecting to {}".format(authority_url)) + raise LoggedCLIError(f"Problem connecting to {authority_url}.") + except Py42MFARequiredError: + totp = prompt("Multi-factor authentication required. Enter TOTP", type=int) + return validate_connection(authority_url, username, password, totp) except Py42UnauthorizedError as err: logger.log_error(str(err)) - raise Code42CLIError("Invalid credentials for user {}".format(username)) + if "INVALID_TIME_BASED_ONE_TIME_PASSWORD" in err.response.text: + raise Code42CLIError(f"Invalid TOTP token for user {username}.") + else: + raise Code42CLIError(f"Invalid credentials for user {username}.") except Exception as err: logger.log_error(str(err)) raise LoggedCLIError("Unknown problem validating connection.") diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 82478371e..fecad002a 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,4 +1,7 @@ import pytest +from py42.exceptions import Py42MFARequiredError +from requests import Response +from requests.exceptions import HTTPError from ..conftest import create_mock_profile from code42cli import PRODUCT_NAME @@ -208,6 +211,29 @@ def test_create_profile_with_password_option_if_credentials_valid_password_saved assert "Would you like to set a password?" not in result.output +def test_create_profile_stores_password_and_prints_message_when_user_requires_mfa( + runner, mocker, mock_verify, mock_cliprofile_namespace +): + mock_verify.side_effect = Py42MFARequiredError(HTTPError(response=Response())) + result = runner.invoke( + cli, + [ + "profile", + "create", + "-n", + "mfa", + "-s", + "bar", + "-u", + "baz", + "--password", + "pass", + ], + ) + assert "Multi-factor account detected." in result.output + mock_cliprofile_namespace.set_password.assert_called_once_with("pass", mocker.ANY) + + def test_create_profile_outputs_confirmation( runner, user_agreement, valid_connection, mock_cliprofile_namespace ): diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 470ae6b56..10aec594f 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -1,14 +1,20 @@ +from io import StringIO + import py42.sdk import py42.settings.debug as debug import pytest +from py42.exceptions import Py42MFARequiredError from py42.exceptions import Py42UnauthorizedError from requests import Response from requests.exceptions import ConnectionError +from requests.exceptions import HTTPError from requests.exceptions import RequestException from .conftest import create_mock_profile from code42cli.errors import Code42CLIError from code42cli.errors import LoggedCLIError +from code42cli.main import cli +from code42cli.options import CLIState from code42cli.sdk_client import create_sdk from code42cli.sdk_client import validate_connection @@ -114,5 +120,46 @@ def mock_get_password(): def test_validate_connection_uses_given_credentials(mock_sdk_factory): - assert validate_connection("Authority", "Test", "Password") - mock_sdk_factory.assert_called_once_with("Authority", "Test", "Password") + assert validate_connection("Authority", "Test", "Password", None) + mock_sdk_factory.assert_called_once_with("Authority", "Test", "Password", totp=None) + + +def test_validate_connection_when_mfa_required_exception_raised_prompts_for_totp( + mocker, monkeypatch, mock_sdk_factory, capsys +): + monkeypatch.setattr("sys.stdin", StringIO("101010")) + response = mocker.MagicMock(spec=Response) + mock_sdk_factory.side_effect = [ + Py42MFARequiredError(HTTPError(response=response)), + None, + ] + validate_connection("Authority", "Test", "Password", None) + output = capsys.readouterr() + assert "Multi-factor authentication required. Enter TOTP:" in output.out + + +def test_validate_connection_when_mfa_token_invalid_raises_expected_cli_error( + mocker, mock_sdk_factory +): + response = mocker.MagicMock(spec=Response) + response.text = '{"data":null,"error":[{"primaryErrorKey":"INVALID_TIME_BASED_ONE_TIME_PASSWORD","otherErrors":null}],"warnings":null}' + mock_sdk_factory.side_effect = Py42UnauthorizedError(HTTPError(response=response)) + with pytest.raises(Code42CLIError) as err: + validate_connection("Authority", "Test", "Password", "1234") + assert str(err.value) == "Invalid TOTP token for user Test." + + +def test_totp_option_when_passed_is_passed_to_sdk_initialization( + mocker, profile, runner +): + mock_py42 = mocker.patch("code42cli.sdk_client.py42.sdk.from_local_account") + cli_state = CLIState() + totp = "1234" + profile.authority_url = "example.com" + profile.username = "user" + profile.get_password.return_value = "password" + cli_state._profile = profile + runner.invoke(cli, ["users", "list", "--totp", totp], obj=cli_state) + mock_py42.assert_called_once_with( + profile.authority_url, profile.username, "password", totp=totp + ) From 5469717b4cc9c1680e6fb191cd5b75a545f5799f Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Wed, 19 May 2021 12:03:53 -0500 Subject: [PATCH 240/349] move 2fa to unreleased in CHANGELOG (#285) --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8b7b011..31313c7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- Support for users that require multi-factor authentication. + ## 1.5.1 - 2021-05-12 ### Fixed @@ -21,8 +27,6 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added -- Support for users that require multi-factor authentication. - - New command `code42 alerts show` that displays information about a single alert. - New command `code42 alerts update` that can update an alert's state or note. From 2a41542864b8a6f45a6bcad5a0886b23bafc4a5e Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 20 May 2021 11:09:52 -0500 Subject: [PATCH 241/349] Release prep (#286) --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31313c7ef..2c8bd5821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.6.0 - 2021-05-20 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 0f228f258..e4adfb83d 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.5.1" +__version__ = "1.6.0" From 8fb73706ea71a65c41ec1d034d445ce0eed33899 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 20 May 2021 14:47:26 -0500 Subject: [PATCH 242/349] add users cmd docs (#287) --- docs/commands.md | 1 + docs/commands/users.rst | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 docs/commands/users.rst diff --git a/docs/commands.md b/docs/commands.md index b1a38c4b0..f523fb790 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,3 +10,4 @@ * [High Risk Employee](commands/highriskemployee.rst) * [Legal Hold](commands/legalhold.rst) * [Cases](commands/cases.rst) +* [Users](commands/users.rst) diff --git a/docs/commands/users.rst b/docs/commands/users.rst new file mode 100644 index 000000000..e63a60b96 --- /dev/null +++ b/docs/commands/users.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.users:users + :prog: users + :show-nested: From eb913cae911f895081f2915d44162834dc172524 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 27 May 2021 10:01:31 -0500 Subject: [PATCH 243/349] Have profile commands use create_sdk so disabling ssl verification works (#288) * have profile commands use create_sdk so disabling ssl verification works * fix totp and tests * make validate_connection internal, test create_sdk instead * CHANGELOG entry * rm PRODUCT_NAME var * fix test names --- CHANGELOG.md | 6 +++ src/code42cli/cmds/profile.py | 4 +- src/code42cli/options.py | 2 +- src/code42cli/sdk_client.py | 22 ++++++---- tests/cmds/test_profile.py | 17 ++++---- tests/test_sdk_client.py | 81 ++++++++++++++++------------------- 6 files changed, 70 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8bd5821..5e5844708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Fixed + +- Issue where `profile` commands that required connecting to an authority failed to respect the `--disable-ssl-errors` flag when set. + ## 1.6.0 - 2021-05-20 ### Added diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index d4ee18927..ca8224bf5 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -9,7 +9,7 @@ from code42cli.errors import Code42CLIError from code42cli.options import yes_option from code42cli.profile import CREATE_PROFILE_HELP -from code42cli.sdk_client import validate_connection +from code42cli.sdk_client import create_sdk from code42cli.util import does_user_agree @@ -193,7 +193,7 @@ def _prompt_for_allow_password_set(profile_name): def _set_pw(profile_name, password): c42profile = cliprofile.get_profile(profile_name) try: - validate_connection(c42profile.authority_url, c42profile.username, password) + create_sdk(c42profile, is_debug_mode=False, password=password) except Py42MFARequiredError: echo( "Multi-factor account detected. `--totp ` option will be required for all code42 invocations." diff --git a/src/code42cli/options.py b/src/code42cli/options.py index ffedfaf82..065db335f 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -60,7 +60,7 @@ def profile(self, value): @property def sdk(self): if self._sdk is None: - self._sdk = create_sdk(self.profile, self.debug, self.totp) + self._sdk = create_sdk(self.profile, self.debug, totp=self.totp) return self._sdk def set_assume_yes(self, param): diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index 54c934f23..20fcd848b 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -7,6 +7,7 @@ from py42.exceptions import Py42MFARequiredError from py42.exceptions import Py42UnauthorizedError from requests.exceptions import ConnectionError +from requests.exceptions import SSLError from code42cli.errors import Code42CLIError from code42cli.errors import LoggedCLIError @@ -17,7 +18,7 @@ logger = get_main_cli_logger() -def create_sdk(profile, is_debug_mode, totp=None): +def create_sdk(profile, is_debug_mode, password=None, totp=None): if is_debug_mode: py42.settings.debug.level = debug.DEBUG if profile.ignore_ssl_errors == "True": @@ -31,25 +32,30 @@ def create_sdk(profile, is_debug_mode, totp=None): requests.packages.urllib3.exceptions.InsecureRequestWarning ) py42.settings.verify_ssl_certs = False - password = profile.get_password() - return validate_connection(profile.authority_url, profile.username, password, totp) + password = password or profile.get_password() + return _validate_connection(profile.authority_url, profile.username, password, totp) -def validate_connection(authority_url, username, password, totp=None): +def _validate_connection(authority_url, username, password, totp=None): try: return py42.sdk.from_local_account(authority_url, username, password, totp=totp) + except SSLError as err: + logger.log_error(err) + raise LoggedCLIError( + f"Problem connecting to {authority_url}, SSL certificate verification failed.\nUpdate profile with --disable-ssl-errors to bypass certificate checks (not recommended!)." + ) except ConnectionError as err: - logger.log_error(str(err)) + logger.log_error(err) raise LoggedCLIError(f"Problem connecting to {authority_url}.") except Py42MFARequiredError: totp = prompt("Multi-factor authentication required. Enter TOTP", type=int) - return validate_connection(authority_url, username, password, totp) + return _validate_connection(authority_url, username, password, totp) except Py42UnauthorizedError as err: - logger.log_error(str(err)) + logger.log_error(err) if "INVALID_TIME_BASED_ONE_TIME_PASSWORD" in err.response.text: raise Code42CLIError(f"Invalid TOTP token for user {username}.") else: raise Code42CLIError(f"Invalid credentials for user {username}.") except Exception as err: - logger.log_error(str(err)) + logger.log_error(err) raise LoggedCLIError("Unknown problem validating connection.") diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index fecad002a..9dcde59bd 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,10 +1,10 @@ import pytest from py42.exceptions import Py42MFARequiredError +from py42.sdk import SDKClient from requests import Response from requests.exceptions import HTTPError from ..conftest import create_mock_profile -from code42cli import PRODUCT_NAME from code42cli.errors import Code42CLIError from code42cli.errors import LoggedCLIError from code42cli.main import cli @@ -12,37 +12,38 @@ @pytest.fixture def user_agreement(mocker): - mock = mocker.patch("{}.cmds.profile.does_user_agree".format(PRODUCT_NAME)) + mock = mocker.patch("code42cli.cmds.profile.does_user_agree") mock.return_value = True return mocker @pytest.fixture def user_disagreement(mocker): - mock = mocker.patch("{}.cmds.profile.does_user_agree".format(PRODUCT_NAME)) + mock = mocker.patch("code42cli.cmds.profile.does_user_agree") mock.return_value = False return mocker @pytest.fixture def mock_cliprofile_namespace(mocker): - return mocker.patch("{}.cmds.profile.cliprofile".format(PRODUCT_NAME)) + return mocker.patch("code42cli.cmds.profile.cliprofile") @pytest.fixture(autouse=True) def mock_getpass(mocker): - mock = mocker.patch("{}.cmds.profile.getpass".format(PRODUCT_NAME)) + mock = mocker.patch("code42cli.cmds.profile.getpass") mock.return_value = "newpassword" @pytest.fixture def mock_verify(mocker): - return mocker.patch("{}.cmds.profile.validate_connection".format(PRODUCT_NAME)) + return mocker.patch("code42cli.cmds.profile.create_sdk") @pytest.fixture -def valid_connection(mock_verify): - mock_verify.return_value = True +def valid_connection(mocker, mock_verify): + mock_sdk = mocker.MagicMock(spec=SDKClient) + mock_verify.return_value = mock_sdk return mock_verify diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 10aec594f..836065143 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -16,7 +16,6 @@ from code42cli.main import cli from code42cli.options import CLIState from code42cli.sdk_client import create_sdk -from code42cli.sdk_client import validate_connection @pytest.fixture @@ -29,6 +28,17 @@ def mock_sdk_factory(mocker): return mocker.patch("py42.sdk.from_local_account") +@pytest.fixture +def mock_profile_with_password(): + profile = create_mock_profile() + + def mock_get_password(): + return "Test Password" + + profile.get_password = mock_get_password + return profile + + @pytest.fixture def requests_exception(mocker): mock_response = mocker.MagicMock(spec=Response) @@ -54,78 +64,63 @@ def test_create_sdk_when_profile_has_ssl_errors_disabled_sets_py42_setting_and_p def test_create_sdk_when_py42_exception_occurs_raises_and_logs_cli_error( - sdk_logger, mock_sdk_factory, requests_exception + sdk_logger, mock_sdk_factory, requests_exception, mock_profile_with_password ): mock_sdk_factory.side_effect = Py42UnauthorizedError(requests_exception) - profile = create_mock_profile() - - def mock_get_password(): - return "Test Password" - profile.get_password = mock_get_password with pytest.raises(Code42CLIError) as err: - create_sdk(profile, False) + create_sdk(mock_profile_with_password, False) assert "Invalid credentials for user" in err.value.message assert sdk_logger.log_error.call_count == 1 - assert "Failure in HTTP call" in sdk_logger.log_error.call_args[0][0] + assert "Failure in HTTP call" in str(sdk_logger.log_error.call_args[0][0]) def test_create_sdk_when_connection_exception_occurs_raises_and_logs_cli_error( - sdk_logger, mock_sdk_factory + sdk_logger, mock_sdk_factory, mock_profile_with_password ): mock_sdk_factory.side_effect = ConnectionError("connection message") - profile = create_mock_profile() - def mock_get_password(): - return "Test Password" - - profile.get_password = mock_get_password with pytest.raises(LoggedCLIError) as err: - create_sdk(profile, False) + create_sdk(mock_profile_with_password, False) assert "Problem connecting to" in err.value.message assert sdk_logger.log_error.call_count == 1 - assert "connection message" in sdk_logger.log_error.call_args[0][0] + assert "connection message" in str(sdk_logger.log_error.call_args[0][0]) def test_create_sdk_when_unknown_exception_occurs_raises_and_logs_cli_error( - sdk_logger, mock_sdk_factory + sdk_logger, mock_sdk_factory, mock_profile_with_password ): mock_sdk_factory.side_effect = Exception("test message") - profile = create_mock_profile() - - def mock_get_password(): - return "Test Password" - profile.get_password = mock_get_password with pytest.raises(LoggedCLIError) as err: - create_sdk(profile, False) + create_sdk(mock_profile_with_password, False) assert "Unknown problem validating" in err.value.message assert sdk_logger.log_error.call_count == 1 - assert "test message" in sdk_logger.log_error.call_args[0][0] - + assert "test message" in str(sdk_logger.log_error.call_args[0][0]) -def test_create_sdk_when_told_to_debug_turns_on_debug(mock_sdk_factory): - profile = create_mock_profile() - - def mock_get_password(): - return "Test Password" - profile.get_password = mock_get_password - create_sdk(profile, True) +def test_create_sdk_when_told_to_debug_turns_on_debug( + mock_sdk_factory, mock_profile_with_password +): + create_sdk(mock_profile_with_password, True) assert py42.settings.debug.level == debug.DEBUG -def test_validate_connection_uses_given_credentials(mock_sdk_factory): - assert validate_connection("Authority", "Test", "Password", None) - mock_sdk_factory.assert_called_once_with("Authority", "Test", "Password", totp=None) +def test_create_sdk_uses_given_credentials( + mock_sdk_factory, mock_profile_with_password +): + create_sdk(mock_profile_with_password, False) + mock_sdk_factory.assert_called_once_with( + "example.com", "foo", "Test Password", totp=None + ) -def test_validate_connection_when_mfa_required_exception_raised_prompts_for_totp( - mocker, monkeypatch, mock_sdk_factory, capsys +def test_create_sdk_connection_when_mfa_required_exception_raised_prompts_for_totp( + mocker, monkeypatch, mock_sdk_factory, capsys, mock_profile_with_password ): monkeypatch.setattr("sys.stdin", StringIO("101010")) response = mocker.MagicMock(spec=Response) @@ -133,20 +128,20 @@ def test_validate_connection_when_mfa_required_exception_raised_prompts_for_totp Py42MFARequiredError(HTTPError(response=response)), None, ] - validate_connection("Authority", "Test", "Password", None) + create_sdk(mock_profile_with_password, False) output = capsys.readouterr() assert "Multi-factor authentication required. Enter TOTP:" in output.out -def test_validate_connection_when_mfa_token_invalid_raises_expected_cli_error( - mocker, mock_sdk_factory +def test_create_sdk_connection_when_mfa_token_invalid_raises_expected_cli_error( + mocker, mock_sdk_factory, mock_profile_with_password ): response = mocker.MagicMock(spec=Response) response.text = '{"data":null,"error":[{"primaryErrorKey":"INVALID_TIME_BASED_ONE_TIME_PASSWORD","otherErrors":null}],"warnings":null}' mock_sdk_factory.side_effect = Py42UnauthorizedError(HTTPError(response=response)) with pytest.raises(Code42CLIError) as err: - validate_connection("Authority", "Test", "Password", "1234") - assert str(err.value) == "Invalid TOTP token for user Test." + create_sdk(mock_profile_with_password, False, totp="1234") + assert str(err.value) == "Invalid TOTP token for user foo." def test_totp_option_when_passed_is_passed_to_sdk_initialization( From 9cb18a9c9705cf9c78ec829615d5f9d94b18fb5d Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 27 May 2021 10:13:09 -0500 Subject: [PATCH 244/349] release prep (#289) --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5844708..4a0f8af7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.6.1 - 2021-05-27 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index e4adfb83d..f49459c74 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.6.0" +__version__ = "1.6.1" From a7a521f0d31f2d1fde08a9b0d92b614d55cbd97b Mon Sep 17 00:00:00 2001 From: maddie-vargo <75453991+maddie-vargo@users.noreply.github.com> Date: Thu, 27 May 2021 10:23:51 -0500 Subject: [PATCH 245/349] Users command: Functionality to add/remove roles + Users user guide (#283) --- CHANGELOG.md | 8 +++ src/code42cli/cmds/users.py | 54 ++++++++++++++++-- tests/cmds/test_users.py | 110 ++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a0f8af7b..2d18fa71d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- New command `code42 users add-role` to add a user role to a single user. + +- New command `code42 users remove-role` to remove a user role from a single user. + ## 1.6.1 - 2021-05-27 ### Fixed diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index b1f48f9c5..d368dcca6 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -4,6 +4,7 @@ from code42cli.click_ext.groups import OrderedGroup from code42cli.click_ext.options import incompatible_with from code42cli.errors import Code42CLIError +from code42cli.errors import UserDoesNotExistError from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import DataFrameOutputFormatter @@ -21,9 +22,6 @@ def users(state): "--org-uid", help="Limit users to only those in the organization you specify. Note that child orgs are included.", ) -role_name_option = click.option( - "--role-name", help="Limit results to only users having the specified role.", -) active_option = click.option( "--active", is_flag=True, help="Limits results to only active users.", default=None, ) @@ -35,9 +33,17 @@ def users(state): ) +def role_name_option(help): + return click.option("--role-name", help=help) + + +def username_option(help): + return click.option("--username", help=help) + + @users.command(name="list") @org_uid_option -@role_name_option +@role_name_option("Limit results to only users having the specified role.") @active_option @inactive_option @format_option @@ -60,6 +66,44 @@ def list_users(state, org_uid, role_name, active, inactive, format): formatter.echo_formatted_dataframe(df) +@users.command() +@username_option("Username of the target user.") +@role_name_option("Name of role to add.") +@sdk_options() +def add_role(state, username, role_name): + """Add the specified role to the user with the specified username.""" + _add_user_role(state.sdk, username, role_name) + + +@users.command() +@role_name_option("Name of role to remove.") +@username_option("Username of the target user.") +@sdk_options() +def remove_role(state, username, role_name): + """Remove the specified role to the user with the specified username.""" + _remove_user_role(state.sdk, role_name, username) + + +def _add_user_role(sdk, username, role_name): + user_id = _get_user_id(sdk, username) + _get_role_id(sdk, role_name) # function provides role name validation + sdk.users.add_role(user_id, role_name) + + +def _remove_user_role(sdk, role_name, username): + user_id = _get_user_id(sdk, username) + _get_role_id(sdk, role_name) # function provides role name validation + sdk.users.remove_role(user_id, role_name) + + +def _get_user_id(sdk, username): + user = sdk.users.get_by_username(username)["users"] + if len(user) == 0: + raise UserDoesNotExistError(username) + user_id = user[0]["userId"] + return user_id + + def _get_role_id(sdk, role_name): try: roles_dataframe = DataFrame.from_records( @@ -68,7 +112,7 @@ def _get_role_id(sdk, role_name): role_result = roles_dataframe.at[role_name, "roleId"] return str(role_result) # extract the role ID from the series except KeyError: - raise Code42CLIError(f"Role with name {role_name} not found.") + raise Code42CLIError(f"Role with name '{role_name}' not found.") def _get_users_dataframe(sdk, columns, org_uid, role_id, active): diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 634575cde..506fbde76 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -32,6 +32,10 @@ } ] } +TEST_EMPTY_USERS_RESPONSE = {"users": []} +TEST_USERNAME = TEST_USERS_RESPONSE["users"][0]["username"] +TEST_USER_ID = TEST_USERS_RESPONSE["users"][0]["userId"] +TEST_ROLE_NAME = TEST_ROLE_RETURN_DATA["data"][0]["roleName"] def _create_py42_response(mocker, text): @@ -56,6 +60,16 @@ def get_all_users_success(cli_state): cli_state.sdk.users.get_all.return_value = get_all_users_generator() +@pytest.fixture +def get_user_id_success(cli_state): + cli_state.sdk.users.get_by_username.return_value = TEST_USERS_RESPONSE + + +@pytest.fixture +def get_user_id_failure(cli_state): + cli_state.sdk.users.get_by_username.return_value = TEST_EMPTY_USERS_RESPONSE + + @pytest.fixture def get_available_roles_success(cli_state, get_available_roles_response): cli_state.sdk.users.get_available_roles.return_value = get_available_roles_response @@ -153,3 +167,99 @@ def test_list_users_when_given_excluding_active_and_inactive_uses_active_equals_ cli_state.sdk.users.get_all.assert_called_once_with( active=None, org_uid=None, role_id=None ) + + +def test_add_user_role_adds( + runner, cli_state, get_user_id_success, get_available_roles_success +): + command = [ + "users", + "add-role", + "--username", + "test.username@example.com", + "--role-name", + "Customer Cloud Admin", + ] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.add_role.assert_called_once_with(TEST_USER_ID, TEST_ROLE_NAME) + + +def test_add_user_role_raises_error_when_role_does_not_exist( + runner, cli_state, get_user_id_success, get_available_roles_success +): + command = [ + "users", + "add-role", + "--username", + "test.username@example.com", + "--role-name", + "test", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert "Role with name 'test' not found." in result.output + + +def test_add_user_role_raises_error_when_username_does_not_exist( + runner, cli_state, get_user_id_failure, get_available_roles_success +): + command = [ + "users", + "add-role", + "--username", + "not_a_username@example.com", + "--role-name", + "Desktop User", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert "User 'not_a_username@example.com' does not exist." in result.output + + +def test_remove_user_role_removes( + runner, cli_state, get_user_id_success, get_available_roles_success +): + command = [ + "users", + "remove-role", + "--username", + "test.username@example.com", + "--role-name", + "Customer Cloud Admin", + ] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.remove_role.assert_called_once_with( + TEST_USER_ID, TEST_ROLE_NAME + ) + + +def test_remove_user_role_raises_error_when_role_does_not_exist( + runner, cli_state, get_user_id_success, get_available_roles_success +): + command = [ + "users", + "remove-role", + "--username", + "test.username@example.com", + "--role-name", + "test", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert "Role with name 'test' not found." in result.output + + +def test_remove_user_role_raises_error_when_username_does_not_exist( + runner, cli_state, get_user_id_failure, get_available_roles_success +): + command = [ + "users", + "remove-role", + "--username", + "not_a_username@example.com", + "--role-name", + "Desktop User", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert "User 'not_a_username@example.com' does not exist." in result.output From a2214e0b6d4a0e8c2065ea06033869cbb2067e4f Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 2 Jun 2021 17:20:15 -0500 Subject: [PATCH 246/349] Fstrs everywhere (#292) --- .pre-commit-config.yaml | 2 +- docs/conf.py | 4 +- src/code42cli/bulk.py | 6 +-- src/code42cli/click_ext/groups.py | 5 +- src/code42cli/click_ext/options.py | 6 +-- src/code42cli/click_ext/types.py | 8 ++- src/code42cli/cmds/alert_rules.py | 18 +++---- src/code42cli/cmds/cases.py | 4 +- src/code42cli/cmds/departing_employee.py | 4 +- src/code42cli/cmds/devices.py | 24 ++++----- src/code42cli/cmds/high_risk_employee.py | 14 +++-- src/code42cli/cmds/legal_hold.py | 14 +++-- src/code42cli/cmds/profile.py | 36 +++++++------ src/code42cli/cmds/search/__init__.py | 2 +- src/code42cli/cmds/search/cursor_store.py | 2 +- src/code42cli/cmds/search/extraction.py | 2 +- src/code42cli/cmds/search/options.py | 11 ++-- src/code42cli/config.py | 2 +- src/code42cli/date_helper.py | 2 +- src/code42cli/errors.py | 10 ++-- src/code42cli/logger/__init__.py | 10 ++-- src/code42cli/logger/formatters.py | 14 +++-- src/code42cli/logger/handlers.py | 5 +- src/code42cli/main.py | 8 ++- src/code42cli/output_formats.py | 7 ++- src/code42cli/password.py | 2 +- src/code42cli/profile.py | 11 ++-- src/code42cli/sdk_client.py | 4 +- src/code42cli/util.py | 6 +-- src/code42cli/worker.py | 4 +- tests/cmds/conftest.py | 2 +- tests/cmds/detectionlists/test_init.py | 12 ++--- tests/cmds/search/test_cursor_store.py | 17 +++--- tests/cmds/search/test_extraction.py | 4 +- tests/cmds/test_alert_rules.py | 26 ++++----- tests/cmds/test_alerts.py | 56 ++++++++----------- tests/cmds/test_departing_employee.py | 27 +++------- tests/cmds/test_devices.py | 13 ++--- tests/cmds/test_high_risk_employee.py | 29 +++++----- tests/cmds/test_legal_hold.py | 57 ++++++++------------ tests/cmds/test_profile.py | 8 ++- tests/cmds/test_securitydata.py | 65 +++++++++-------------- tests/conftest.py | 6 +-- tests/integration/conftest.py | 2 +- tests/integration/test_alerts.py | 6 +-- tests/integration/test_auditlogs.py | 4 +- tests/logger/test_formatters.py | 2 +- tests/logger/test_init.py | 2 +- tests/test_bulk.py | 4 +- tests/test_output_formats.py | 6 +-- tests/test_password.py | 9 ++-- tests/test_profile.py | 11 ++-- tests/test_sdk_client.py | 6 +-- tests/test_util.py | 3 +- 54 files changed, 260 insertions(+), 364 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78db37064..132ee5794 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: rev: v2.7.1 hooks: - id: pyupgrade - args: ["--py3-plus"] + args: ["--py36-plus"] - repo: https://github.com/asottile/reorder_python_imports rev: v2.3.0 hooks: diff --git a/docs/conf.py b/docs/conf.py index ed8b9e9f0..c5f9eb311 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,9 +23,9 @@ author = "Code42 Software" # The short X.Y version -version = "code42cli v{}".format(meta.__version__) +version = f"code42cli v{meta.__version__}" # The full version, including alpha/beta/rc tags -release = "code42cli v{}".format(meta.__version__) +release = f"code42cli v{meta.__version__}" # -- General configuration --------------------------------------------------- diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index a87c3ebb8..39c5f5ef5 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -23,9 +23,7 @@ def write_template_file(path, columns=None, flat_item=None): new_file.write(",".join(columns)) else: new_file.write( - "# This template takes a single {} to be processed on each row.".format( - flat_item or "item" - ) + f"# This template takes a single {flat_item or 'item'} to be processed on each row." ) @@ -58,7 +56,7 @@ def generate_template_cmd_factory(group_name, commands_dict, help_message=None): def generate_template(cmd, path): columns = commands_dict[cmd] if not path: - filename = "{}_bulk_{}.csv".format(group_name, cmd.replace("-", "_")) + filename = f"{group_name}_bulk_{cmd.replace('-', '_')}.csv" path = os.path.join(os.getcwd(), filename) if isinstance(columns, str): write_template_file(path, columns=None, flat_item=columns) diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index bdf559eec..ec0bd2fb4 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -102,8 +102,9 @@ def _suggest_cmd(usage_err): ) if not suggested_commands: raise usage_err - usage_err.message = "No such command '{}'. Did you mean {}?".format( - bad_arg, " or ".join(suggested_commands) + usage_err.message = ( + f"No such command '{bad_arg}'. " + f"Did you mean {' or '.join(suggested_commands)}?" ) raise usage_err diff --git a/src/code42cli/click_ext/options.py b/src/code42cli/click_ext/options.py index 8964db96d..bbbdc8ea0 100644 --- a/src/code42cli/click_ext/options.py +++ b/src/code42cli/click_ext/options.py @@ -18,7 +18,7 @@ def handle_parse_result(self, ctx, opts, args): if ctx.obj is not None: found_incompatible = ", ".join( [ - "--{}".format(opt.replace("_", "-")) + f"--{opt.replace('_', '-')}" for opt in opts if opt in incompatible_opts ] @@ -27,9 +27,7 @@ def handle_parse_result(self, ctx, opts, args): name = self.name.replace("_", "-") raise click.BadOptionUsage( option_name=self.name, - message="--{} can't be used with: {}".format( - name, found_incompatible - ), + message=f"--{name} can't be used with: {found_incompatible}", ) return super().handle_parse_result(ctx, opts, args) diff --git a/src/code42cli/click_ext/types.py b/src/code42cli/click_ext/types.py index be5d80143..d0d6f679f 100644 --- a/src/code42cli/click_ext/types.py +++ b/src/code42cli/click_ext/types.py @@ -115,9 +115,7 @@ def _get_dt_from_magic_time_pair(num, period): elif period == "m": delta = timedelta(minutes=num) else: - raise BadParameter( - "Couldn't parse magic time string: {}{}".format(num, period) - ) + raise BadParameter(f"Couldn't parse magic time string: {num}{period}") return datetime.utcnow() - delta @staticmethod @@ -127,11 +125,11 @@ def _get_dt_from_date_time_pair(date, time): time = "{}:{}:{}".format(*time.split(":") + ["00", "00"]) else: time = "00:00:00" - date_string = "{} {}".format(date, time) + date_string = f"{date} {time}" try: dt = datetime.strptime(date_string, date_format) except ValueError: - raise BadParameter("Unable to parse date string: {}.".format(date_string)) + raise BadParameter(f"Unable to parse date string: {date_string}.") else: return dt diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index 3b1fec7f2..0e034eedc 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -73,7 +73,7 @@ def remove_user(state, rule_id, username): _remove_user(state.sdk, rule_id, username) except Py42BadRequestError: raise Code42CLIError( - "User {} is not currently assigned to rule-id {}.".format(username, rule_id) + f"User {username} is not currently assigned to rule-id {rule_id}." ) @@ -118,9 +118,8 @@ def bulk(state): @bulk.command( - help="Bulk add users to alert rules from a CSV file. CSV file format: {}".format( - ",".join(ALERT_RULES_CSV_HEADERS) - ) + help=f"Bulk add users to alert rules from a CSV file. " + f"CSV file format: {','.join(ALERT_RULES_CSV_HEADERS)}" ) @read_csv_arg(headers=ALERT_RULES_CSV_HEADERS) @sdk_options() @@ -136,9 +135,8 @@ def handle_row(rule_id, username): @bulk.command( - help="Bulk remove users from alert rules using a CSV file. CSV file format: {}".format( - ",".join(ALERT_RULES_CSV_HEADERS) - ) + help="Bulk remove users from alert rules using a CSV file. " + "CSV file format: {','.join(ALERT_RULES_CSV_HEADERS)}" ) @read_csv_arg(headers=ALERT_RULES_CSV_HEADERS) @sdk_options() @@ -182,8 +180,8 @@ def _get_rule_metadata(sdk, rule_id): def _handle_rules_results(rules, rule_id=None): if not rules: - id_msg = "with RuleId {} ".format(rule_id) if rule_id else "" - msg = "No alert rules {}found.".format(id_msg) + id_msg = f"with RuleId {rule_id} " if rule_id else "" + msg = f"No alert rules {id_msg}found." raise Code42CLIError(msg) return rules @@ -198,5 +196,5 @@ def _get_rule_type_func(sdk, rule_type): else: raise Code42CLIError( "Received an unknown rule type from server. You might need to update " - "to a newer version of {}".format(PRODUCT_NAME) + f"to a newer version of {PRODUCT_NAME}" ) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index be415d5af..b37df6039 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -193,7 +193,7 @@ def show(state, case_number, format, include_file_events): events = _get_file_events(state.sdk, case_number) _display_file_events(events) except Py42NotFoundError: - raise Code42CLIError("Invalid case-number {}.".format(case_number)) + raise Code42CLIError(f"Invalid case-number {case_number}.") @cases.command() @@ -207,7 +207,7 @@ def show(state, case_number, format, include_file_events): def export(state, case_number, path): """Download a case detail summary as a PDF file at the given path with name _case_summary.pdf.""" response = state.sdk.cases.export_summary(case_number) - file = os.path.join(path, "{}_case_summary.pdf".format(case_number)) + file = os.path.join(path, f"{case_number}_case_summary.pdf") with open(file, "wb") as f: f.write(response.content) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index ab696c570..9ff558a66 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -28,7 +28,7 @@ def _get_filter_choices(): DATE_FORMAT = "%Y-%m-%d" filter_option = click.option( "--filter", - help="Departing employee filter options. Defaults to {}.".format(ALL_FILTER), + help=f"Departing employee filter options. Defaults to {ALL_FILTER}.", type=click.Choice(_get_filter_choices()), default=ALL_FILTER, callback=lambda ctx, param, arg: handle_filter_choice(arg), @@ -96,7 +96,7 @@ def bulk(state): @bulk.command( name="add", help="Bulk add users to the Departing Employees detection list using a CSV file with " - "format: {}.".format(",".join(DEPARTING_EMPLOYEE_CSV_HEADERS)), + f"format: {','.join(DEPARTING_EMPLOYEE_CSV_HEADERS)}.", ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @sdk_options() diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 689c3a254..a86ce9fd8 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -83,9 +83,7 @@ def _deactivate_device(sdk, device_guid, change_device_name, purge_date): try: device = _change_device_activation(sdk, device_guid, "deactivate") except exceptions.Py42BadRequestError: - raise Code42CLIError( - "The device with GUID '{}' is in legal hold.".format(device_guid) - ) + raise Code42CLIError(f"The device with GUID '{device_guid}' is in legal hold.") if purge_date: _update_cold_storage_purge_date(sdk, device_guid, purge_date) if change_device_name and not device.data["name"].startswith("deactivated_"): @@ -113,12 +111,10 @@ def _change_device_activation(sdk, device_guid, cmd_str): sdk.devices.deactivate(device_id) return device except exceptions.Py42NotFoundError: - raise Code42CLIError( - "The device with GUID '{}' was not found.".format(device_guid) - ) + raise Code42CLIError(f"The device with GUID '{device_guid}' was not found.") except exceptions.Py42ForbiddenError: raise Code42CLIError( - "Unable to {} the device with GUID '{}'.".format(cmd_str, device_guid) + f"Unable to {cmd_str} the device with GUID '{device_guid}'." ) @@ -509,14 +505,12 @@ def _add_backup_set_settings_to_dataframe(sdk, devices_dataframe): def handle_row(guid): try: current_device_settings = sdk.devices.get_settings(guid) - except Exception as e: + except Exception as err: return DataFrame.from_records( [ { "guid": guid, - "ERROR": "Unable to retrieve device settings for {}: {}".format( - guid, e - ), + "ERROR": f"Unable to retrieve device settings for {guid}: {err}", } ] ) @@ -591,8 +585,8 @@ def handle_row(**row): sdk, row["guid"], row["change_device_name"], row["purge_date"] ) row["deactivated"] = "True" - except Exception as e: - row["deactivated"] = "False: {}".format(e) + except Exception as err: + row["deactivated"] = f"False: {err}" return row result_rows = run_bulk_process( @@ -615,8 +609,8 @@ def handle_row(**row): try: _reactivate_device(sdk, row["guid"]) row["reactivated"] = "True" - except Exception as e: - row["reactivated"] = "False: {}".format(e) + except Exception as err: + row["reactivated"] = f"False: {err}" return row result_rows = run_bulk_process( diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 987ea5707..620ef2621 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -30,7 +30,7 @@ def _get_filter_choices(): filter_option = click.option( "--filter", - help="High risk employee filter options. Defaults to {}.".format(ALL_FILTER), + help=f"High risk employee filter options. Defaults to {ALL_FILTER}.", type=click.Choice(_get_filter_choices()), default=ALL_FILTER, callback=lambda ctx, param, arg: handle_filter_choice(arg), @@ -126,7 +126,7 @@ def bulk(state): @bulk.command( name="add", help="Bulk add users to the high risk employees detection list using a CSV file with " - "format: {}.".format(",".join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)), + f"format: {','.join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)}.", ) @read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) @sdk_options() @@ -165,9 +165,8 @@ def handle_row(username): @bulk.command( name="add-risk-tags", - help="Adds risk tags to users in bulk using a CSV file with format: {}.".format( - ",".join(RISK_TAG_CSV_HEADERS) - ), + help=f"Adds risk tags to users in bulk using a CSV file with format: " + f"{','.join(RISK_TAG_CSV_HEADERS)}.", ) @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) @sdk_options() @@ -184,9 +183,8 @@ def handle_row(username, tag): @bulk.command( name="remove-risk-tags", - help="Removes risk tags from users in bulk using a CSV file with format: {}.".format( - ",".join(RISK_TAG_CSV_HEADERS) - ), + help=f"Removes risk tags from users in bulk using a CSV file with format: " + f"{','.join(RISK_TAG_CSV_HEADERS)}.", ) @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) @sdk_options() diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index 21eda14bb..b8fe02343 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -190,9 +190,8 @@ def bulk(state): @bulk.command( name="add", - help="Bulk add custodians to legal hold matters using a CSV file. CSV file format: {}".format( - ",".join(LEGAL_HOLD_CSV_HEADERS) - ), + help=f"Bulk add custodians to legal hold matters using a CSV file. " + f"CSV file format: {','.join(LEGAL_HOLD_CSV_HEADERS)}", ) @read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) @sdk_options() @@ -206,9 +205,8 @@ def handle_row(matter_id, username): @bulk.command( - help="Bulk release custodians from legal hold matters using a CSV file. CSV file format: {}".format( - ",".join(LEGAL_HOLD_CSV_HEADERS) - ) + help=f"Bulk release custodians from legal hold matters using a CSV file. " + f"CSV file format: {','.join(LEGAL_HOLD_CSV_HEADERS)}" ) @read_csv_arg(headers=LEGAL_HOLD_CSV_HEADERS) @sdk_options() @@ -287,10 +285,10 @@ def _get_all_events(sdk, legal_hold_uid, begin_date, end_date): def _print_matter_members(username_list, member_type="active"): if username_list: - echo("\n{} matter members:\n".format(member_type.capitalize())) + echo(f"\n{member_type.capitalize()} matter members:\n") format_string_list_to_columns(username_list) else: - echo("No {} matter members.\n".format(member_type)) + echo(f"No {member_type} matter members.\n") @lru_cache(maxsize=None) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index ca8224bf5..b79548a3a 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -70,10 +70,10 @@ def username_option(required=False): def show(profile_name): """Print the details of a profile.""" c42profile = cliprofile.get_profile(profile_name) - echo("\n{}:".format(c42profile.name)) - echo("\t* username = {}".format(c42profile.username)) - echo("\t* authority url = {}".format(c42profile.authority_url)) - echo("\t* ignore-ssl-errors = {}".format(c42profile.ignore_ssl_errors)) + echo(f"\n{c42profile.name}:") + echo(f"\t* username = {c42profile.username}") + echo(f"\t* authority url = {c42profile.authority_url}") + echo(f"\t* ignore-ssl-errors = {c42profile.ignore_ssl_errors}") if cliprofile.get_stored_password(c42profile.name) is not None: echo("\t* A password is set.") echo("") @@ -94,7 +94,7 @@ def create(name, server, username, password, disable_ssl_errors): _set_pw(name, password) else: _prompt_for_allow_password_set(name) - echo("Successfully created profile '{}'.".format(name)) + echo(f"Successfully created profile '{name}'.") @profile.command() @@ -119,7 +119,7 @@ def update(name, server, username, password, disable_ssl_errors): elif not c42profile.has_stored_password: _prompt_for_allow_password_set(c42profile.name) - echo("Profile '{}' has been updated.".format(c42profile.name)) + echo(f"Profile '{c42profile.name}' has been updated.") @profile.command() @@ -130,7 +130,7 @@ def reset_pw(profile_name): does not make any changes to the Code42 user account.""" password = getpass() profile_name_saved = _set_pw(profile_name, password) - echo("Password updated for profile '{}'.".format(profile_name_saved)) + echo(f"Password updated for profile '{profile_name_saved}'.") @profile.command("list") @@ -148,7 +148,7 @@ def _list(): def use(profile_name): """Set a profile as the default.""" cliprofile.switch_default_profile(profile_name) - echo("{} has been set as the default profile.".format(profile_name)) + echo(f"{profile_name} has been set as the default profile.") @profile.command() @@ -156,14 +156,15 @@ def use(profile_name): @profile_name_arg(required=True) def delete(profile_name): """Deletes a profile and its stored password (if any).""" - message = "\nDeleting this profile will also delete any stored passwords and checkpoints. Are you sure? (y/n): " + message = ( + "\nDeleting this profile will also delete any stored passwords and checkpoints. " + "Are you sure? (y/n): " + ) if cliprofile.is_default_profile(profile_name): - message = "\n'{}' is currently the default profile!\n{}".format( - profile_name, message - ) + message = f"\n'{profile_name}' is currently the default profile!\n{message}" if does_user_agree(message): cliprofile.delete_profile(profile_name) - echo("Profile '{}' has been deleted.".format(profile_name)) + echo(f"Profile '{profile_name}' has been deleted.") @profile.command() @@ -172,14 +173,17 @@ def delete_all(): """Deletes all profiles and saved passwords (if any).""" existing_profiles = cliprofile.get_all_profiles() if existing_profiles: + profile_str_list = "\n\t".join( + [c42profile.name for c42profile in existing_profiles] + ) message = ( - "\nAre you sure you want to delete the following profiles?\n\t{}" + f"\nAre you sure you want to delete the following profiles?\n\t{profile_str_list}" "\n\nThis will also delete any stored passwords and checkpoints. (y/n): " - ).format("\n\t".join([c42profile.name for c42profile in existing_profiles])) + ) if does_user_agree(message): for profile_obj in existing_profiles: cliprofile.delete_profile(profile_obj.name) - echo("Profile '{}' has been deleted.".format(profile_obj.name)) + echo(f"Profile '{profile_obj.name}' has been deleted.") else: echo("\nNo profiles exist. Nothing to delete.") diff --git a/src/code42cli/cmds/search/__init__.py b/src/code42cli/cmds/search/__init__.py index b6cef2b19..1cdbfb599 100644 --- a/src/code42cli/cmds/search/__init__.py +++ b/src/code42cli/cmds/search/__init__.py @@ -11,7 +11,7 @@ def _try_get_logger_for_server(hostname, protocol, output_format, certs): return get_logger_for_server(hostname, protocol, output_format, certs) except Exception as err: raise Code42CLIError( - "Unable to connect to {}. Failed with error: {}.".format(hostname, str(err)) + f"Unable to connect to {hostname}. Failed with error: {err}." ) diff --git a/src/code42cli/cmds/search/cursor_store.py b/src/code42cli/cmds/search/cursor_store.py index ae13e3841..e02fcd321 100644 --- a/src/code42cli/cmds/search/cursor_store.py +++ b/src/code42cli/cmds/search/cursor_store.py @@ -46,7 +46,7 @@ def delete(self, cursor_name): location = path.join(self._dir_path, cursor_name) os.remove(location) except FileNotFoundError: - msg = "No checkpoint named {} exists for this profile.".format(cursor_name) + msg = f"No checkpoint named {cursor_name} exists for this profile." raise Code42CLIError(msg) def clean(self): diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py index e15d4746f..bcca9b297 100644 --- a/src/code42cli/cmds/search/extraction.py +++ b/src/code42cli/cmds/search/extraction.py @@ -54,7 +54,7 @@ def handle_error(exception): errors.ERRORED = True if hasattr(exception, "response") and hasattr(exception.response, "text"): - message = "{}: {}".format(exception, exception.response.text) + message = f"{exception}: {exception.response.text}" else: message = exception logger.log_error(message) diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index 86513f1c2..79c920354 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -95,9 +95,8 @@ def handle_parse_result(self, ctx, opts, args): checkpoint_value, timezone.utc ).isoformat() click.echo( - "Ignoring --begin value as --use-checkpoint was passed and checkpoint of {} exists.\n".format( - checkpoint_value_str - ), + "Ignoring --begin value as --use-checkpoint was passed and checkpoint of " + f"{checkpoint_value_str} exists.\n", err=True, ) if ( @@ -122,11 +121,9 @@ def _parse_query_from_json(ctx, param, arg): filter_groups = [FilterGroup.from_dict(group) for group in query["groups"]] return filter_groups except json.JSONDecodeError as json_error: - raise click.BadParameter("Unable to parse JSON: {}".format(json_error)) + raise click.BadParameter(f"Unable to parse JSON: {json_error}") except KeyError as key_error: - raise click.BadParameter( - "Unable to build query from input JSON: {}".format(key_error) - ) + raise click.BadParameter(f"Unable to build query from input JSON: {key_error}") def advanced_query_option(term, **kwargs): diff --git a/src/code42cli/config.py b/src/code42cli/config.py index fddd8fbdc..81f6fbceb 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -7,7 +7,7 @@ class NoConfigProfileError(Exception): def __init__(self, profile_arg_name=None): message = ( - "Profile '{}' does not exist.".format(profile_arg_name) + f"Profile '{profile_arg_name}' does not exist." if profile_arg_name else "Profile does not exist." ) diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py index 251882b2b..fef52e837 100644 --- a/src/code42cli/date_helper.py +++ b/src/code42cli/date_helper.py @@ -38,7 +38,7 @@ def limit_date_range(dt, max_days_back=90, param=None): now = datetime.utcnow().replace(tzinfo=timezone.utc) if now - dt > timedelta(days=max_days_back): raise click.BadParameter( - message="must be within {} days.".format(max_days_back), param=param + message=f"must be within {max_days_back} days.", param=param ) return dt diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index 6ae1493c0..416859201 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -20,7 +20,7 @@ def show(self, file=None): """Override default `show` to print CLI errors in red text.""" if file is None: file = get_text_stderr() - click.secho("Error: {}".format(self.format_message()), file=file, fg="red") + click.secho(f"Error: {self.format_message()}", file=file, fg="red") if self.help: click.echo(self.help, err=True) @@ -39,7 +39,7 @@ def __init__(self, message=None): def format_message(self): locations_message = get_view_error_details_message() return ( - "{}\n{}".format(self.message, locations_message) + f"{self.message}\n{locations_message}" if self.message else locations_message ) @@ -51,13 +51,11 @@ class UserDoesNotExistError(Code42CLIError): bulk add or remove.""" def __init__(self, username): - super().__init__("User '{}' does not exist.".format(username)) + super().__init__(f"User '{username}' does not exist.") class UserNotInLegalHoldError(Code42CLIError): def __init__(self, username, matter_id): super().__init__( - "User '{}' is not an active member of legal hold matter '{}'.".format( - username, matter_id - ) + f"User '{username}' is not an active member of legal hold matter '{matter_id}'." ) diff --git a/src/code42cli/logger/__init__.py b/src/code42cli/logger/__init__.py index 3bff40ae2..5e8350ae0 100644 --- a/src/code42cli/logger/__init__.py +++ b/src/code42cli/logger/__init__.py @@ -43,7 +43,7 @@ def get_logger_for_server(hostname, protocol, output_format, certs): output_format: CEF, JSON, or RAW_JSON. Each type results in a different logger instance. certs: Use for passing SSL/TLS certificates when connecting to the server. """ - logger = logging.getLogger("code42_syslog_{}".format(output_format.lower())) + logger = logging.getLogger(f"code42_syslog_{output_format.lower()}") if logger_has_handlers(logger): return logger @@ -101,7 +101,7 @@ def _get_error_file_logger(): def get_view_error_details_message(): """Returns the error message that is printed when errors occur.""" path = _get_error_log_path() - return "View details in {}".format(path) + return f"View details in {path}" def _create_formatter_for_error_file(): @@ -123,13 +123,13 @@ def log_verbose_error(self, invocation_str=None, http_request=None): prefix = ( "Exception occurred." if not invocation_str - else "Exception occurred from input: '{}'.".format(invocation_str) + else f"Exception occurred from input: '{invocation_str}'." ) - message = "{}. See error below.".format(prefix) + message = f"{prefix}. See error below." self.log_error(message) self.log_error(traceback.format_exc()) if http_request: - self.log_error("Request parameters: {}".format(http_request.body)) + self.log_error(f"Request parameters: {http_request.body}") def get_main_cli_logger(): diff --git a/src/code42cli/logger/formatters.py b/src/code42cli/logger/formatters.py index 85d4e83c8..f2373329a 100644 --- a/src/code42cli/logger/formatters.py +++ b/src/code42cli/logger/formatters.py @@ -82,7 +82,7 @@ def _format_cef_kvp(cef_field_key, cef_field_value): cef_field_value = _convert_list_to_csv(cef_field_value) elif cef_field_key in CEF_TIMESTAMP_FIELDS: cef_field_value = convert_file_event_timestamp_to_cef_timestamp(cef_field_value) - return "{}={}".format(cef_field_key, cef_field_value) + return f"{cef_field_key}={cef_field_value}" def _handle_nested_json_fields(cef_field_key, cef_field_value): @@ -96,13 +96,11 @@ def _handle_nested_json_fields(cef_field_key, cef_field_value): def _format_custom_cef_kvp(custom_cef_field_key, custom_cef_field_value): - custom_cef_label_key = "{}Label".format(custom_cef_field_key) + custom_cef_label_key = f"{custom_cef_field_key}Label" custom_cef_label_value = CEF_CUSTOM_FIELD_NAME_MAP[custom_cef_label_key] - return "{}={} {}={}".format( - custom_cef_field_key, - custom_cef_field_value, - custom_cef_label_key, - custom_cef_label_value, + return ( + f"{custom_cef_field_key}={custom_cef_field_value} " + f"{custom_cef_label_key}={custom_cef_label_value}" ) @@ -116,7 +114,7 @@ def convert_file_event_timestamp_to_cef_timestamp(timestamp_value): _datetime = datetime.strptime(timestamp_value, "%Y-%m-%dT%H:%M:%S.%fZ") except ValueError: _datetime = datetime.strptime(timestamp_value, "%Y-%m-%dT%H:%M:%SZ") - value = "{:.0f}".format(_datetime_to_ms_since_epoch(_datetime)) + value = f"{_datetime_to_ms_since_epoch(_datetime):.0f}" return value diff --git a/src/code42cli/logger/handlers.py b/src/code42cli/logger/handlers.py index d4cb6850e..5f37458ed 100644 --- a/src/code42cli/logger/handlers.py +++ b/src/code42cli/logger/handlers.py @@ -143,7 +143,8 @@ def _get_socket_type_from_protocol(protocol): def _raise_socket_type_error(protocol): - msg = "Could not determine socket type. Expected one of {}, but got {}.".format( - list(ServerProtocol()), protocol + msg = ( + "Could not determine socket type. " + f"Expected one of {list(ServerProtocol())}, but got {protocol}." ) raise ValueError(msg) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index a462911f3..5e2a61274 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -23,16 +23,14 @@ from code42cli.cmds.users import users from code42cli.options import sdk_options -BANNER = """\b +BANNER = f"""\b dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. dP `" dP Yb 8I Yb 88__ dP 88 "' dP' Yb Yb dP 8I dY 88"" d888888 dP' YboodP YbodP 8888Y" 888888 88 .d8888 -code42cli version {}, by Code42 Software. -powered by py42 version {}.""".format( - cliversion, py42version -) +code42cli version {cliversion}, by Code42 Software. +powered by py42 version {py42version}.""" # Handle KeyboardInterrupts by just exiting instead of printing out a stack diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index b78911647..6a24b007e 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -157,13 +157,12 @@ def to_table(output, header): def to_json(output): """Output is a single record""" - return "{}\n".format(json.dumps(output)) + return f"{json.dumps(output)}\n" def to_formatted_json(output): """Output is a single record""" - json_str = "{}\n".format(json.dumps(output, indent=4)) - return json_str + return f"{json.dumps(output, indent=4)}\n" class FileEventsOutputFormat(OutputFormat): @@ -185,7 +184,7 @@ def __init__(self, output_format, header=None): def to_cef(output): """Output is a single record""" - return "{}\n".format(_convert_event_to_cef(output)) + return f"{_convert_event_to_cef(output)}\n" def _convert_event_to_cef(event): diff --git a/src/code42cli/password.py b/src/code42cli/password.py index badd7c2c9..4f7fd1908 100644 --- a/src/code42cli/password.py +++ b/src/code42cli/password.py @@ -34,7 +34,7 @@ def delete_password(profile): def _get_keyring_service_name(profile_name): - return "{}::{}".format(PRODUCT_NAME, profile_name) + return f"{PRODUCT_NAME}::{profile_name}" def _prompt_for_alternative_store(): diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index f7c7cf678..a16bd4840 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -40,8 +40,8 @@ def get_password(self): return pwd def __str__(self): - return "{}: Username={}, Authority URL={}".format( - self.name, self.username, self.authority_url + return ( + f"{self.name}: Username={self.username}, Authority URL={self.authority_url}" ) @@ -101,7 +101,7 @@ def switch_default_profile(profile_name): def create_profile(name, server, username, ignore_ssl_errors): if profile_exists(name): - raise Code42CLIError("A profile named '{}' already exists.".format(name)) + raise Code42CLIError(f"A profile named '{name}' already exists.") config_accessor.create_profile(name, server, username, ignore_ssl_errors) @@ -139,7 +139,10 @@ def set_password(new_password, profile_name=None): CREATE_PROFILE_HELP = "\nTo add a profile, use:\n{}".format( style( - "\tcode42 profile create --name --server --username \n", + "\tcode42 profile create " + "--name " + "--server " + "--username \n", bold=True, ) ) diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index 20fcd848b..fecb3fc5b 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -23,8 +23,8 @@ def create_sdk(profile, is_debug_mode, password=None, totp=None): py42.settings.debug.level = debug.DEBUG if profile.ignore_ssl_errors == "True": secho( - "Warning: Profile '{}' has SSL verification disabled. Adding certificate verification " - "is strongly advised.".format(profile.name), + f"Warning: Profile '{profile.name}' has SSL verification disabled. " + "Adding certificate verification is strongly advised.", fg="red", err=True, ) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index cc392bbd3..3ac5d1678 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -32,7 +32,7 @@ def get_user_project_path(*subdirs): """The path on your user dir to /.code42cli/[subdir].""" package_name = __name__.split(".")[0] home = path.expanduser("~") - hidden_package_name = ".{}".format(package_name) + hidden_package_name = f".{package_name}" user_project_path = path.join(home, hidden_package_name) result_path = path.join(user_project_path, *subdirs) if not path.exists(result_path): @@ -90,7 +90,7 @@ def format_string_list_to_columns(string_list, max_width=None): max_width, _ = shutil.get_terminal_size() column_width = len(max(string_list, key=len)) + _PADDING_SIZE num_columns = int(max_width / column_width) or 1 - format_string = "{{:<{0}}}".format(column_width) * num_columns + format_string = f"{{:<{column_width}}}" * num_columns batches = [ string_list[i : i + num_columns] for i in range(0, len(string_list), num_columns) @@ -134,7 +134,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def _handle_interrupts(self, sig, frame): if not self.interrupted: self.interrupted = True - echo("\n{}\n{}".format(self.warning, self.exit_instructions), err=True) + echo(f"\n{self.warning}\n{self.exit_instructions}", err=True) else: exit() diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 3080837de..a673dc8ee 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -43,9 +43,7 @@ def results(self): return self._results def __str__(self): - return "{} succeeded, {} failed out of {}".format( - self.total_successes, self._total_errors, self.total - ) + return f"{self.total_successes} succeeded, {self._total_errors} failed out of {self.total}" def increment_total_processed(self): """+1 to self.total_processed""" diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index c456e2a74..deeb49deb 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -118,7 +118,7 @@ def get_generator_for_get_all(mocker, mock_return_items): def gen(*args, **kwargs): response = mocker.MagicMock(spec=Request) - response.text = """{{"items": [{0}]}}""".format(mock_return_items) + response.text = f'{{"items": [{mock_return_items}]}}' yield Py42Response(response) return gen diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index 9e50887c8..b0e853b41 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -8,20 +8,18 @@ MOCK_USER_ID = "USER-ID" MOCK_USER_NAME = "test@example.com" MOCK_ALIAS = "alias@example" -MOCK_USER_PROFILE_RESPONSE = """ +MOCK_USER_PROFILE_RESPONSE = f""" {{ "type$": "USER_V2", "tenantId": "TENANT-ID", - "userId": "{0}", - "userName": "{1}", + "userId": "{MOCK_USER_ID}", + "userName": "{MOCK_USER_NAME}", "displayName": "Test", "notes": "Notes", - "cloudUsernames": ["{2}", "{1}"], + "cloudUsernames": ["{MOCK_ALIAS}", "{MOCK_USER_NAME}"], "riskFactors": ["HIGH_IMPACT_EMPLOYEE"] }} -""".format( - MOCK_USER_ID, MOCK_USER_NAME, MOCK_ALIAS -) +""" @pytest.fixture diff --git a/tests/cmds/search/test_cursor_store.py b/tests/cmds/search/test_cursor_store.py index ec63278b1..6e1fbcda2 100644 --- a/tests/cmds/search/test_cursor_store.py +++ b/tests/cmds/search/test_cursor_store.py @@ -2,7 +2,6 @@ import pytest -from code42cli import PRODUCT_NAME from code42cli.cmds.search.cursor_store import AlertCursorStore from code42cli.cmds.search.cursor_store import AuditLogCursorStore from code42cli.cmds.search.cursor_store import Cursor @@ -12,7 +11,7 @@ PROFILE_NAME = "testprofile" CURSOR_NAME = "testcursor" -_NAMESPACE = "{}.cmds.search.cursor_store".format(PRODUCT_NAME) +_NAMESPACE = "code42cli.cmds.search.cursor_store" ALERT_CHECKPOINT_FOLDER_NAME = "alert_checkpoints" FILE_EVENT_CHECKPOINT_FOLDER_NAME = "file_event_checkpoints" @@ -34,9 +33,7 @@ def mock_open_events(mocker): mock = mocker.patch( "builtins.open", mocker.mock_open( - read_data='["{}", "{}"]'.format( - AUDIT_LOG_EVENT_HASH_1, AUDIT_LOG_EVENT_HASH_2 - ) + read_data=f'["{AUDIT_LOG_EVENT_HASH_1}", "{AUDIT_LOG_EVENT_HASH_2}"]' ), ) return mock @@ -44,7 +41,7 @@ def mock_open_events(mocker): @pytest.fixture def mock_isfile(mocker): - mock = mocker.patch("{}.os.path.isfile".format(_NAMESPACE)) + mock = mocker.patch(f"{_NAMESPACE}.os.path.isfile") mock.return_value = True return mock @@ -73,7 +70,7 @@ def test_get_returns_expected_timestamp(self, mock_open): def test_get_when_profile_does_not_exist_returns_none(self, mocker): store = AlertCursorStore(PROFILE_NAME) checkpoint = store.get(CURSOR_NAME) - mock_open = mocker.patch("{}.open".format(_NAMESPACE)) + mock_open = mocker.patch(f"{_NAMESPACE}.open") mock_open.side_effect = FileNotFoundError assert checkpoint is None @@ -159,7 +156,7 @@ def test_get_reads_expected_file(self, mock_open): def test_get_when_profile_does_not_exist_returns_none(self, mocker): store = FileEventCursorStore(PROFILE_NAME) checkpoint = store.get(CURSOR_NAME) - mock_open = mocker.patch("{}.open".format(_NAMESPACE)) + mock_open = mocker.patch(f"{_NAMESPACE}.open") mock_open.side_effect = FileNotFoundError assert checkpoint is None @@ -234,7 +231,7 @@ def test_get_reads_expected_file(self, mock_open): def test_get_when_profile_does_not_exist_returns_none(self, mocker): store = AuditLogCursorStore(PROFILE_NAME) checkpoint = store.get(CURSOR_NAME) - mock_open = mocker.patch("{}.open".format(_NAMESPACE)) + mock_open = mocker.patch(f"{_NAMESPACE}.open") mock_open.side_effect = FileNotFoundError assert checkpoint is None @@ -308,7 +305,7 @@ def test_get_events_reads_expected_file(self, mock_open): def test_get_events_when_profile_does_not_exist_returns_empty_list(self, mocker): store = AuditLogCursorStore(PROFILE_NAME) event_list = store.get_events(CURSOR_NAME) - mock_open = mocker.patch("{}.open".format(_NAMESPACE)) + mock_open = mocker.patch(f"{_NAMESPACE}.open") mock_open.side_effect = FileNotFoundError assert event_list == [] diff --git a/tests/cmds/search/test_extraction.py b/tests/cmds/search/test_extraction.py index 312b331c1..13eeb0f7f 100644 --- a/tests/cmds/search/test_extraction.py +++ b/tests/cmds/search/test_extraction.py @@ -61,7 +61,7 @@ def _get_timestamp_from_item(self, item): ) http_response = mocker.MagicMock(spec=Response) events = [{"property": "bar"}] - http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) + http_response.text = f'{{"{key}": [{{"property": "bar"}}]}}' py42_response = Py42Response(http_response) handlers.handle_response(py42_response) formatter.echo_formatted_list.assert_called_once_with(events, force_pager=False) @@ -84,7 +84,7 @@ def _get_timestamp_from_item(self, item): ) http_response = mocker.MagicMock(spec=Response) events = [{"property": "bar"}] - http_response.text = '{{"{0}": [{{"property": "bar"}}]}}'.format(key) + http_response.text = f'{{"{key}": [{{"property": "bar"}}]}}' py42_response = Py42Response(http_response) handlers.handle_response(py42_response) event_extractor_logger.info.assert_called_once_with(events[0]) diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index b5e96b8b5..bdb84554c 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -281,7 +281,7 @@ def test_show_rule_when_no_matching_rule_prints_no_rule_message(runner, cli_stat TEST_EMPTY_RULE_RESPONSE ) result = runner.invoke(cli, ["alert-rules", "show", TEST_RULE_ID], obj=cli_state) - msg = "No alert rules with RuleId {} found".format(TEST_RULE_ID) + msg = f"No alert rules with RuleId {TEST_RULE_ID} found" assert msg in result.output @@ -340,9 +340,7 @@ def test_remove_when_user_not_on_rule_raises_expected_error(runner, cli_state, m obj=cli_state, ) assert ( - "User {} is not currently assigned to rule-id {}.".format( - test_username, test_rule_id - ) + f"User {test_username} is not currently assigned to rule-id {test_rule_id}." in result.output ) @@ -351,24 +349,18 @@ def test_remove_when_user_not_on_rule_raises_expected_error(runner, cli_state, m "command, error_msg", [ ( - "{} add-user --rule-id test-rule-id".format(ALERT_RULES_COMMAND), + f"{ALERT_RULES_COMMAND} add-user --rule-id test-rule-id", "Missing option '-u' / '--username'.", ), ( - "{} remove-user --rule-id test-rule-id".format(ALERT_RULES_COMMAND), + f"{ALERT_RULES_COMMAND} remove-user --rule-id test-rule-id", "Missing option '-u' / '--username'.", ), - ("{} add-user".format(ALERT_RULES_COMMAND), "Missing option '--rule-id'."), - ("{} remove-user".format(ALERT_RULES_COMMAND), "Missing option '--rule-id'."), - ("{} show".format(ALERT_RULES_COMMAND), "Missing argument 'RULE_ID'."), - ( - "{} bulk add".format(ALERT_RULES_COMMAND), - "Error: Missing argument 'CSV_FILE'.", - ), - ( - "{} bulk remove".format(ALERT_RULES_COMMAND), - "Error: Missing argument 'CSV_FILE'.", - ), + (f"{ALERT_RULES_COMMAND} add-user", "Missing option '--rule-id'."), + (f"{ALERT_RULES_COMMAND} remove-user", "Missing option '--rule-id'."), + (f"{ALERT_RULES_COMMAND} show", "Missing argument 'RULE_ID'."), + (f"{ALERT_RULES_COMMAND} bulk add", "Error: Missing argument 'CSV_FILE'.",), + (f"{ALERT_RULES_COMMAND} bulk remove", "Error: Missing argument 'CSV_FILE'.",), ], ) def test_alert_rules_command_when_missing_required_parameters_errors( diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index a6938556b..2cb52c9b5 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -300,14 +300,14 @@ @pytest.fixture def alert_extractor(mocker): - mock = mocker.patch("{}.cmds.alerts._get_alert_extractor".format(PRODUCT_NAME)) + mock = mocker.patch(f"{PRODUCT_NAME}.cmds.alerts._get_alert_extractor") mock.return_value = mocker.MagicMock(spec=AlertExtractor) return mock.return_value @pytest.fixture def alert_cursor_with_checkpoint(mocker): - mock = mocker.patch("{}.cmds.alerts._get_alert_cursor_store".format(PRODUCT_NAME)) + mock = mocker.patch(f"{PRODUCT_NAME}.cmds.alerts._get_alert_cursor_store") mock_cursor = mocker.MagicMock(spec=AlertCursorStore) mock_cursor.get.return_value = CURSOR_TIMESTAMP mock.return_value = mock_cursor @@ -317,7 +317,7 @@ def alert_cursor_with_checkpoint(mocker): @pytest.fixture def alert_cursor_without_checkpoint(mocker): - mock = mocker.patch("{}.cmds.alerts._get_alert_cursor_store".format(PRODUCT_NAME)) + mock = mocker.patch(f"{PRODUCT_NAME}.cmds.alerts._get_alert_cursor_store") mock_cursor = mocker.MagicMock(spec=AlertCursorStore) mock_cursor.get.return_value = None mock.return_value = mock_cursor @@ -326,9 +326,7 @@ def alert_cursor_without_checkpoint(mocker): @pytest.fixture def begin_option(mocker): - mock = mocker.patch( - "{}.cmds.alerts.convert_datetime_to_timestamp".format(PRODUCT_NAME) - ) + mock = mocker.patch(f"{PRODUCT_NAME}.cmds.alerts.convert_datetime_to_timestamp") mock.return_value = BEGIN_TIMESTAMP mock.expected_timestamp = "2020-01-01T06:00:00.000Z" return mock @@ -336,7 +334,7 @@ def begin_option(mocker): @pytest.fixture def alert_extract_func(mocker): - return mocker.patch("{}.cmds.alerts._extract".format(PRODUCT_NAME)) + return mocker.patch(f"{PRODUCT_NAME}.cmds.alerts._extract") @pytest.fixture @@ -401,7 +399,7 @@ def test_search_with_advanced_query_and_incompatible_argument_errors( obj=cli_state, ) assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + assert f"{arg[0]} can't be used with: --advanced-query" in result.output @advanced_query_incompat_test_params @@ -415,7 +413,7 @@ def test_send_to_with_advanced_query_and_incompatible_argument_errors( obj=cli_state, ) assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + assert f"{arg[0]} can't be used with: --advanced-query" in result.output @search_and_send_to_test @@ -430,9 +428,9 @@ def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( ) filters = alert_extractor.extract.call_args[0][0] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{}T00:00:00.000Z".format(begin_date) + expected_begin = f"{begin_date}T00:00:00.000Z" actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{}T23:59:59.999Z".format(end_date) + expected_end = f"{end_date}T23:59:59.999Z" assert actual_begin == expected_begin assert actual_end == expected_end @@ -446,20 +444,14 @@ def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( time = "15:33:02" runner.invoke( cli, - [ - *command, - "--begin", - "{} {}".format(begin_date, time), - "--end", - "{} {}".format(end_date, time), - ], + [*command, "--begin", f"{begin_date} {time}", "--end", f"{end_date} {time}"], obj=cli_state, ) filters = alert_extractor.extract.call_args[0][0] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{}T{}.000Z".format(begin_date, time) + expected_begin = f"{begin_date}T{time}.000Z" actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{}T{}.000Z".format(end_date, time) + expected_end = f"{end_date}T{time}.000Z" assert actual_begin == expected_begin assert actual_end == expected_end @@ -470,11 +462,11 @@ def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_que ): date = get_test_date_str(days_ago=89) time = "15:33" - runner.invoke(cli, [*command, "--begin", "{} {}".format(date, time)], obj=cli_state) + runner.invoke(cli, [*command, "--begin", f"{date} {time}"], obj=cli_state) actual = get_filter_value_from_json( alert_extractor.extract.call_args[0][0], filter_index=0 ) - expected = "{}T{}:00.000Z".format(date, time) + expected = f"{date}T{time}:00.000Z" assert actual == expected @@ -487,13 +479,13 @@ def test_search_and_send_to_when_given_end_date_and_time_uses_expected_query( time = "15:33" runner.invoke( cli, - [*command, "--begin", begin_date, "--end", "{} {}".format(end_date, time)], + [*command, "--begin", begin_date, "--end", f"{end_date} {time}"], obj=cli_state, ) actual = get_filter_value_from_json( alert_extractor.extract.call_args[0][0], filter_index=1 ) - expected = "{}T{}:00.000Z".format(end_date, time) + expected = f"{end_date}T{time}:00.000Z" assert actual == expected @@ -529,7 +521,7 @@ def test_search_and_send_to_when_given_begin_date_and_not_use_checkpoint_and_cur actual_ts = get_filter_value_from_json( alert_extractor.extract.call_args[0][0], filter_index=0 ) - expected_ts = "{}T00:00:00.000Z".format(begin_date) + expected_ts = f"{begin_date}T00:00:00.000Z" assert actual_ts == expected_ts assert filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) @@ -553,10 +545,10 @@ def test_search_and_send_to_with_only_begin_calls_extract_with_expected_filters( ): res = runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) assert res.exit_code == 0 - assert str( - alert_extractor.extract.call_args[0][0] - ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"createdAt", "value":"{}"}}]}}'.format( - begin_option.expected_timestamp + assert ( + str(alert_extractor.extract.call_args[0][0]) + == '{"filterClause":"AND", "filters":[{"operator":"ON_OR_AFTER", "term":"createdAt", ' + f'"value":"{begin_option.expected_timestamp}"}}]}}' ) @@ -601,9 +593,7 @@ def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_c assert result.exit_code == 0 assert alert_extractor.extract.call_count == 1 assert ( - "checkpoint of {} exists".format( - alert_cursor_with_checkpoint.expected_timestamp - ) + f"checkpoint of {alert_cursor_with_checkpoint.expected_timestamp} exists" in result.output ) @@ -778,7 +768,7 @@ def test_search_and_send_to_with_or_query_flag_produces_expected_query( { "operator": "ON_OR_AFTER", "term": "createdAt", - "value": "{}T00:00:00.000Z".format(begin_date), + "value": f"{begin_date}T00:00:00.000Z", } ], }, diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index f16447c19..493b77ea6 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -188,7 +188,7 @@ def test_add_departing_employee_when_user_does_not_exist_exits( cli, ["departing-employee", "add", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output + assert f"User '{TEST_EMPLOYEE}' does not exist." in result.output def test_add_departing_employee_when_user_already_exits_with_correct_message( @@ -202,7 +202,7 @@ def add_user(user): cli, ["departing-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user ) assert result.exit_code == 1 - assert "'{}' is already on the departing-employee list.".format(TEST_EMPLOYEE) + assert f"'{TEST_EMPLOYEE}' is already on the departing-employee list." def test_remove_departing_employee_calls_remove(runner, cli_state_with_user): @@ -221,7 +221,7 @@ def test_remove_departing_employee_when_user_does_not_exist_exits( cli, ["departing-employee", "remove", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output + assert f"User '{TEST_EMPLOYEE}' does not exist." in result.output def test_add_bulk_users_calls_expected_py42_methods(runner, cli_state): @@ -333,9 +333,7 @@ def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( cli, ["departing-employee", "remove", test_username], obj=cli_state ) assert ( - "User with ID '{}' is not currently on the departing-employee list.".format( - TEST_ID - ) + f"User with ID '{TEST_ID}' is not currently on the departing-employee list." in result.output ) @@ -343,19 +341,10 @@ def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( @pytest.mark.parametrize( "command, error_msg", [ - ("{} add".format(DEPARTING_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), - ( - "{} remove".format(DEPARTING_EMPLOYEE_COMMAND), - "Missing argument 'USERNAME'.", - ), - ( - "{} bulk add".format(DEPARTING_EMPLOYEE_COMMAND), - "Missing argument 'CSV_FILE'.", - ), - ( - "{} bulk remove".format(DEPARTING_EMPLOYEE_COMMAND), - "Missing argument 'FILE'.", - ), + (f"{DEPARTING_EMPLOYEE_COMMAND} add", "Missing argument 'USERNAME'."), + (f"{DEPARTING_EMPLOYEE_COMMAND} remove", "Missing argument 'USERNAME'.",), + (f"{DEPARTING_EMPLOYEE_COMMAND} bulk add", "Missing argument 'CSV_FILE'.",), + (f"{DEPARTING_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'FILE'.",), ], ) def test_departing_employee_command_when_missing_required_parameters_returns_error( diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 31b5062dc..bb10618e9 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -13,7 +13,6 @@ from py42.response import Py42Response from requests import Response -from code42cli import PRODUCT_NAME from code42cli.cmds.devices import _add_backup_set_settings_to_dataframe from code42cli.cmds.devices import _add_legal_hold_membership_to_device_dataframe from code42cli.cmds.devices import _add_usernames_to_device_dataframe @@ -21,7 +20,7 @@ from code42cli.cmds.devices import _get_device_dataframe from code42cli.main import cli -_NAMESPACE = "{}.cmds.devices".format(PRODUCT_NAME) +_NAMESPACE = "code42cli.cmds.devices" TEST_DATE_OLDER = "2020-01-01T12:00:00.774Z" TEST_DATE_NEWER = "2021-01-01T12:00:00.774Z" TEST_DATE_MIDDLE = "2020-06-01T12:00:00" @@ -539,10 +538,7 @@ def test_deactivate_fails_if_device_does_not_exist( cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state ) assert result.exit_code == 1 - assert ( - "The device with GUID '{}' was not found.".format(TEST_DEVICE_GUID) - in result.output - ) + assert f"The device with GUID '{TEST_DEVICE_GUID}' was not found." in result.output def test_deactivate_fails_if_device_is_on_legal_hold( @@ -553,8 +549,7 @@ def test_deactivate_fails_if_device_is_on_legal_hold( ) assert result.exit_code == 1 assert ( - "The device with GUID '{}' is in legal hold.".format(TEST_DEVICE_GUID) - in result.output + f"The device with GUID '{TEST_DEVICE_GUID}' is in legal hold." in result.output ) @@ -566,7 +561,7 @@ def test_deactivate_fails_if_device_deactivation_forbidden( ) assert result.exit_code == 1 assert ( - "Unable to deactivate the device with GUID '{}'.".format(TEST_DEVICE_GUID) + f"Unable to deactivate the device with GUID '{TEST_DEVICE_GUID}'." in result.output ) diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 2bec47bcf..a2a8dab0e 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -200,7 +200,7 @@ def test_add_high_risk_employee_when_user_does_not_exist_exits_with_correct_mess cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output + assert f"User '{TEST_EMPLOYEE}' does not exist." in result.output def test_add_high_risk_employee_when_user_already_added_exits_with_correct_message( @@ -234,7 +234,7 @@ def test_remove_high_risk_employee_when_user_does_not_exist_exits_with_correct_m cli, ["high-risk-employee", "remove", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert "User '{}' does not exist.".format(TEST_EMPLOYEE) in result.output + assert f"User '{TEST_EMPLOYEE}' does not exist." in result.output def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state): @@ -280,7 +280,7 @@ def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state): def test_bulk_remove_employees_uses_expected_arguments(runner, cli_state, mocker): - bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: csv.writelines(["# username\n", "test@example.com\n", "test2@example.com"]) @@ -296,7 +296,7 @@ def test_bulk_remove_employees_uses_expected_arguments(runner, cli_state, mocker def test_bulk_add_risk_tags_uses_expected_arguments(runner, cli_state, mocker): - bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_add_risk_tags.csv", "w") as csv: csv.writelines( @@ -314,7 +314,7 @@ def test_bulk_add_risk_tags_uses_expected_arguments(runner, cli_state, mocker): def test_bulk_remove_risk_tags_uses_expected_arguments(runner, cli_state, mocker): - bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_remove_risk_tags.csv", "w") as csv: csv.writelines( @@ -347,9 +347,7 @@ def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( cli, ["high-risk-employee", "remove", test_username], obj=cli_state ) assert ( - "User with ID '{}' is not currently on the high-risk-employee list.".format( - TEST_ID - ) + f"User with ID '{TEST_ID}' is not currently on the high-risk-employee list." in result.output ) @@ -357,16 +355,13 @@ def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( @pytest.mark.parametrize( "command, error_msg", [ - ("{} add".format(HR_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), - ("{} remove".format(HR_EMPLOYEE_COMMAND), "Missing argument 'USERNAME'."), - ("{} bulk add".format(HR_EMPLOYEE_COMMAND), "Missing argument 'CSV_FILE'."), - ("{} bulk remove".format(HR_EMPLOYEE_COMMAND), "Missing argument 'FILE'."), - ( - "{} bulk add-risk-tags".format(HR_EMPLOYEE_COMMAND), - "Missing argument 'CSV_FILE'.", - ), + (f"{HR_EMPLOYEE_COMMAND} add", "Missing argument 'USERNAME'."), + (f"{HR_EMPLOYEE_COMMAND} remove", "Missing argument 'USERNAME'."), + (f"{HR_EMPLOYEE_COMMAND} bulk add", "Missing argument 'CSV_FILE'."), + (f"{HR_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'FILE'."), + (f"{HR_EMPLOYEE_COMMAND} bulk add-risk-tags", "Missing argument 'CSV_FILE'."), ( - "{} bulk remove-risk-tags".format(HR_EMPLOYEE_COMMAND), + f"{HR_EMPLOYEE_COMMAND} bulk remove-risk-tags", "Missing argument 'CSV_FILE'.", ), ], diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index 1b157e850..01888658e 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -6,12 +6,11 @@ from requests import HTTPError from requests import Response -from code42cli import PRODUCT_NAME from code42cli.cmds.legal_hold import _check_matter_is_accessible from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.main import cli -_NAMESPACE = "{}.cmds.legal_hold".format(PRODUCT_NAME) +_NAMESPACE = "code42cli.cmds.legal_hold" TEST_MATTER_ID = "99999" TEST_LEGAL_HOLD_MEMBERSHIP_UID = "88888" TEST_LEGAL_HOLD_MEMBERSHIP_UID_2 = "77777" @@ -208,9 +207,9 @@ }, ] } -EMPTY_EVENTS_RESPONSE = """{"legalHoldEvents": []}""" -EMPTY_MATTERS_RESPONSE = """{"legalHolds": []}""" -ALL_MATTERS_RESPONSE = """{{"legalHolds": [{}]}}""".format(MATTER_RESPONSE) +EMPTY_EVENTS_RESPONSE = '{"legalHoldEvents": []}' +EMPTY_MATTERS_RESPONSE = '{"legalHolds": []}' +ALL_MATTERS_RESPONSE = f'{{"legalHolds": [{MATTER_RESPONSE}]}}' LEGAL_HOLD_COMMAND = "legal-hold" @@ -327,9 +326,7 @@ def test_add_user_raises_user_already_added_error_when_user_already_on_hold( obj=cli_state, ) assert result.exit_code == 1 - assert "'{}' is already on the legal hold matter id={}".format( - ACTIVE_TEST_USERNAME, TEST_MATTER_ID - ) + assert f"'{ACTIVE_TEST_USERNAME}' is already on the legal hold matter id={TEST_MATTER_ID}" def test_add_user_raises_legalhold_not_found_error_if_matter_inaccessible( @@ -348,8 +345,9 @@ def test_add_user_raises_legalhold_not_found_error_if_matter_inaccessible( obj=cli_state, ) assert result.exit_code == 1 - assert "Matter with id={} either does not exist or your profile does not have permission to view it.".format( - TEST_MATTER_ID + assert ( + f"Matter with id={TEST_MATTER_ID} either does not exist or your profile does not have " + f"permission to view it." ) @@ -390,8 +388,8 @@ def test_remove_user_raises_legalhold_not_found_error_if_matter_inaccessible( ) assert result.exit_code == 1 assert ( - "Matter with id={} either does not exist or your profile does not have " - "permission to view it.".format(TEST_MATTER_ID) + f"Matter with id={TEST_MATTER_ID} either does not exist or your profile does not have " + "permission to view it." ) @@ -418,8 +416,9 @@ def test_remove_user_raises_user_not_in_matter_error_if_user_not_active_in_matte obj=cli_state, ) assert result.exit_code == 1 - assert "User '{}' is not an active member of legal hold matter '{}'".format( - ACTIVE_TEST_USERNAME, TEST_MATTER_ID + assert ( + f"User '{ACTIVE_TEST_USERNAME}' is not an active member of legal hold matter " + f"'{TEST_MATTER_ID}'" ) @@ -578,7 +577,7 @@ def test_show_matter_does_not_print_preservation_policy( def test_add_bulk_users_uses_expected_arguments(runner, mocker, cli_state): - bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_add.csv", "w") as csv: csv.writelines(["matter_id,username\n", "test,value\n"]) @@ -589,7 +588,7 @@ def test_add_bulk_users_uses_expected_arguments(runner, mocker, cli_state): def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state): - bulk_processor = mocker.patch("{}.run_bulk_process".format(_NAMESPACE)) + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: csv.writelines(["matter_id,username\n", "test,value\n"]) @@ -678,30 +677,18 @@ def test_search_events_when_no_results_outputs_no_results(runner, cli_state): "command, error_msg", [ ( - "{} add-user --matter-id test-matter-id".format(LEGAL_HOLD_COMMAND), + f"{LEGAL_HOLD_COMMAND} add-user --matter-id test-matter-id", "Missing option '-u' / '--username'.", ), ( - "{} remove-user --matter-id test-matter-id".format(LEGAL_HOLD_COMMAND), + f"{LEGAL_HOLD_COMMAND} remove-user --matter-id test-matter-id", "Missing option '-u' / '--username'.", ), - ( - "{} add-user".format(LEGAL_HOLD_COMMAND), - "Missing option '-m' / '--matter-id'.", - ), - ( - "{} remove-user".format(LEGAL_HOLD_COMMAND), - "Missing option '-m' / '--matter-id'.", - ), - ("{} show".format(LEGAL_HOLD_COMMAND), "Missing argument 'MATTER_ID'."), - ( - "{} bulk add".format(LEGAL_HOLD_COMMAND), - "Error: Missing argument 'CSV_FILE'.", - ), - ( - "{} bulk remove".format(LEGAL_HOLD_COMMAND), - "Error: Missing argument 'CSV_FILE'.", - ), + (f"{LEGAL_HOLD_COMMAND} add-user", "Missing option '-m' / '--matter-id'.",), + (f"{LEGAL_HOLD_COMMAND} remove-user", "Missing option '-m' / '--matter-id'.",), + (f"{LEGAL_HOLD_COMMAND} show", "Missing argument 'MATTER_ID'."), + (f"{LEGAL_HOLD_COMMAND} bulk add", "Error: Missing argument 'CSV_FILE'.",), + (f"{LEGAL_HOLD_COMMAND} bulk remove", "Error: Missing argument 'CSV_FILE'.",), ], ) def test_legal_hold_command_when_missing_required_parameters_returns_error( diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 9dcde59bd..e79072dee 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -446,8 +446,8 @@ def test_delete_all_does_not_warn_if_assume_yes_flag(runner, mock_cliprofile_nam assert ( "Are you sure you want to delete the following profiles?" not in result.output ) - assert "Profile '{}' has been deleted.".format("test1") in result.output - assert "Profile '{}' has been deleted.".format("test2") in result.output + assert "Profile 'test1' has been deleted." in result.output + assert "Profile 'test2' has been deleted." in result.output def test_delete_all_profiles_does_nothing_if_user_doesnt_agree( @@ -529,6 +529,4 @@ def test_use_profile(runner, mock_cliprofile_namespace, profile): mock_cliprofile_namespace.switch_default_profile.assert_called_once_with( profile.name ) - assert ( - "{} has been set as the default profile.".format(profile.name) in result.output - ) + assert f"{profile.name} has been set as the default profile." in result.output diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index c08972c23..91dfd3cb0 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -12,7 +12,6 @@ from tests.conftest import get_test_date_str from code42cli import errors -from code42cli import PRODUCT_NAME from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.logger.enums import ServerProtocol from code42cli.main import cli @@ -135,18 +134,14 @@ @pytest.fixture def file_event_extractor(mocker): - mock = mocker.patch( - "{}.cmds.securitydata._get_file_event_extractor".format(PRODUCT_NAME) - ) + mock = mocker.patch("code42cli.cmds.securitydata._get_file_event_extractor") mock.return_value = mocker.MagicMock(spec=FileEventExtractor) return mock.return_value @pytest.fixture def file_event_cursor_with_checkpoint(mocker): - mock = mocker.patch( - "{}.cmds.securitydata._get_file_event_cursor_store".format(PRODUCT_NAME) - ) + mock = mocker.patch("code42cli.cmds.securitydata._get_file_event_cursor_store") mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) mock_cursor.get.return_value = CURSOR_TIMESTAMP mock.return_value = mock_cursor @@ -156,9 +151,7 @@ def file_event_cursor_with_checkpoint(mocker): @pytest.fixture def file_event_cursor_without_checkpoint(mocker): - mock = mocker.patch( - "{}.cmds.securitydata._get_file_event_cursor_store".format(PRODUCT_NAME) - ) + mock = mocker.patch("code42cli.cmds.securitydata._get_file_event_cursor_store") mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) mock_cursor.get.return_value = None mock.return_value = mock_cursor @@ -167,9 +160,7 @@ def file_event_cursor_without_checkpoint(mocker): @pytest.fixture def begin_option(mocker): - mock = mocker.patch( - "{}.cmds.securitydata.convert_datetime_to_timestamp".format(PRODUCT_NAME) - ) + mock = mocker.patch("code42cli.cmds.securitydata.convert_datetime_to_timestamp") mock.return_value = BEGIN_TIMESTAMP mock.expected_timestamp = "2020-01-01T06:00:00.000Z" return mock @@ -250,7 +241,7 @@ def test_search_with_advanced_query_and_incompatible_argument_errors( obj=cli_state, ) assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + assert f"{arg[0]} can't be used with: --advanced-query" in result.output @advanced_query_incompat_test_params @@ -270,7 +261,7 @@ def test_send_to_with_advanced_query_and_incompatible_argument_errors( obj=cli_state, ) assert result.exit_code == 2 - assert "{} can't be used with: --advanced-query".format(arg[0]) in result.output + assert f"{arg[0]} can't be used with: --advanced-query" in result.output @saved_search_incompat_test_params @@ -283,7 +274,7 @@ def test_search_with_saved_search_and_incompatible_argument_errors( obj=cli_state, ) assert result.exit_code == 2 - assert "{} can't be used with: --saved-search".format(arg[0]) in result.output + assert f"{arg[0]} can't be used with: --saved-search" in result.output @saved_search_incompat_test_params @@ -296,7 +287,7 @@ def test_send_to_with_saved_search_and_incompatible_argument_errors( obj=cli_state, ) assert result.exit_code == 2 - assert "{} can't be used with: --saved-search".format(arg[0]) in result.output + assert f"{arg[0]} can't be used with: --saved-search" in result.output @pytest.mark.parametrize("protocol", (ServerProtocol.UDP, ServerProtocol.TCP)) @@ -364,9 +355,9 @@ def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( ) filters = file_event_extractor.extract.call_args[0][1] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{}T00:00:00.000Z".format(begin_date) + expected_begin = f"{begin_date}T00:00:00.000Z" actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{}T23:59:59.999Z".format(end_date) + expected_end = f"{end_date}T23:59:59.999Z" assert actual_begin == expected_begin assert actual_end == expected_end @@ -380,20 +371,14 @@ def test_search_and_send_to_when_given_begin_and_end_date_and_time_uses_expected time = "15:33:02" runner.invoke( cli, - [ - *command, - "--begin", - "{} {}".format(begin_date, time), - "--end", - "{} {}".format(end_date, time), - ], + [*command, "--begin", f"{begin_date} {time}", "--end", f"{end_date} {time}"], obj=cli_state, ) filters = file_event_extractor.extract.call_args[0][1] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = "{}T{}.000Z".format(begin_date, time) + expected_begin = f"{begin_date}T{time}.000Z" actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = "{}T{}.000Z".format(end_date, time) + expected_end = f"{end_date}T{time}.000Z" assert actual_begin == expected_begin assert actual_end == expected_end @@ -405,12 +390,12 @@ def test_search_and_send_to_when_given_begin_date_and_time_without_seconds_uses_ date = get_test_date_str(days_ago=89) time = "15:33" runner.invoke( - cli, [*command, "--begin", "{} {}".format(date, time)], obj=cli_state, + cli, [*command, "--begin", f"{date} {time}"], obj=cli_state, ) actual = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 ) - expected = "{}T{}:00.000Z".format(date, time) + expected = f"{date}T{time}:00.000Z" assert actual == expected @@ -423,13 +408,13 @@ def test_search_and_send_to_when_given_end_date_and_time_uses_expected_query( time = "15:33" runner.invoke( cli, - [*command, "--begin", begin_date, "--end", "{} {}".format(end_date, time)], + [*command, "--begin", begin_date, "--end", f"{end_date} {time}"], obj=cli_state, ) actual = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=1 ) - expected = "{}T{}:00.000Z".format(end_date, time) + expected = f"{end_date}T{time}:00.000Z" assert actual == expected @@ -470,7 +455,7 @@ def test_search_and_send_to_when_given_begin_date_and_not_use_checkpoint_and_cur actual_ts = get_filter_value_from_json( file_event_extractor.extract.call_args[0][1], filter_index=0 ) - expected_ts = "{}T00:00:00.000Z".format(begin_date) + expected_ts = f"{begin_date}T00:00:00.000Z" assert actual_ts == expected_ts assert filter_term_is_in_call_args(file_event_extractor, f.EventTimestamp._term) @@ -494,10 +479,10 @@ def test_search_and_send_to_with_only_begin_calls_extract_with_expected_args( ): result = runner.invoke(cli, [*command, "--begin", "1h"], obj=cli_state) assert result.exit_code == 0 - assert str( - file_event_extractor.extract.call_args[0][1] - ) == '{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"{}"}}]}}'.format( - begin_option.expected_timestamp + assert ( + str(file_event_extractor.extract.call_args[0][1]) + == f'{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"eventTimestamp", ' + f'"value":"{begin_option.expected_timestamp}"}}]}}' ) @@ -542,9 +527,7 @@ def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_c assert result.exit_code == 0 assert len(file_event_extractor.extract.call_args[0]) == 1 assert ( - "checkpoint of {} exists".format( - file_event_cursor_with_checkpoint.expected_timestamp - ) + f"checkpoint of {file_event_cursor_with_checkpoint.expected_timestamp} exists" in result.output ) @@ -852,7 +835,7 @@ def test_search_and_send_to_with_or_query_flag_produces_expected_query( { "operator": "ON_OR_AFTER", "term": "eventTimestamp", - "value": "{}T00:00:00.000Z".format(begin_date), + "value": f"{begin_date}T00:00:00.000Z", }, ], }, diff --git a/tests/conftest.py b/tests/conftest.py index 3bc45e433..66fc9520b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -227,10 +227,10 @@ def get_test_date_str(days_ago): begin_date_str = get_test_date_str(days_ago=89) -begin_date_str_with_time = "{} 3:12:33".format(begin_date_str) -begin_date_str_with_t_time = "{}T3:12:33".format(begin_date_str) +begin_date_str_with_time = f"{begin_date_str} 3:12:33" +begin_date_str_with_t_time = f"{begin_date_str}T3:12:33" end_date_str = get_test_date_str(days_ago=10) -end_date_str_with_time = "{} 11:22:43".format(end_date_str) +end_date_str_with_time = f"{end_date_str} 11:22:43" begin_date_str = get_test_date_str(days_ago=89) begin_date_with_time = [get_test_date_str(days_ago=89), "3:12:33"] end_date_str = get_test_date_str(days_ago=10) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d42c49acb..dffbe487b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -46,7 +46,7 @@ def _encode_response(line, encoding_type=_ENCODING_TYPE): def append_profile(command): - return "{} --profile {}".format(command, TEST_PROFILE_NAME) + return f"{command} --profile {TEST_PROFILE_NAME}" @pytest.fixture(scope="session") diff --git a/tests/integration/test_alerts.py b/tests/integration/test_alerts.py index f08334ab2..992998ced 100644 --- a/tests/integration/test_alerts.py +++ b/tests/integration/test_alerts.py @@ -18,7 +18,7 @@ def test_alerts_search_command_returns_success_return_code( runner, integration_test_profile ): - command = "alerts search -b {} -e {}".format(begin_date_str, end_date_str) + command = f"alerts search -b {begin_date_str} -e {end_date_str}" assert_test_is_successful(runner, append_profile(command)) @@ -48,10 +48,10 @@ def test_alerts_send_to_udp_returns_success_return_code( def test_alerts_advanced_query_returns_success_return_code( runner, integration_test_profile ): - ADVANCED_QUERY = """{"groupClause":"AND", "groups":[{"filterClause":"AND", + advanced_query = """{"groupClause":"AND", "groups":[{"filterClause":"AND", "filters":[{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"2020-09-13T00:00:00.000Z"}, {"operator":"ON_OR_BEFORE", "term":"eventTimestamp", "value":"2020-12-07T13:20:15.195Z"}]}], "srtDir":"asc", "srtKey":"eventId", "pgNum":1, "pgSize":10000} """ - command = "alerts search --advanced-query '{}'".format(ADVANCED_QUERY) + command = f"alerts search --advanced-query '{advanced_query}'" assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_auditlogs.py b/tests/integration/test_auditlogs.py index 2fc6951bf..c7eedc20b 100644 --- a/tests/integration/test_auditlogs.py +++ b/tests/integration/test_auditlogs.py @@ -41,7 +41,7 @@ def test_auditlogs_send_to_udp_command_returns_success_return_code( def test_auditlogs_search_command_with_short_hand_begin_returns_success_return_code( runner, integration_test_profile ): - command = "audit-logs search -b '{}'".format(begin_date_str) + command = f"audit-logs search -b '{begin_date_str}'" assert_test_is_successful(runner, append_profile(command)) @@ -49,5 +49,5 @@ def test_auditlogs_search_command_with_short_hand_begin_returns_success_return_c def test_auditlogs_search_command_with_full_begin_returns_success_return_code( runner, integration_test_profile, ): - command = "audit-logs search --begin '{}'".format(begin_date_str) + command = f"audit-logs search --begin '{begin_date_str}'" assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/logger/test_formatters.py b/tests/logger/test_formatters.py index a3606892a..6515ffa10 100644 --- a/tests/logger/test_formatters.py +++ b/tests/logger/test_formatters.py @@ -533,7 +533,7 @@ def get_cef_parts(cef_str): def key_value_pair_in_cef_extension(field_name, field_value, cef_str): cef_parts = get_cef_parts(cef_str) - kvp = "{}={}".format(field_name, field_value) + kvp = f"{field_name}={field_value}" return kvp in cef_parts[-1] diff --git a/tests/logger/test_init.py b/tests/logger/test_init.py index 16c259b78..ca424d181 100644 --- a/tests/logger/test_init.py +++ b/tests/logger/test_init.py @@ -59,7 +59,7 @@ def test_logger_has_handlers_when_logger_does_not_have_handlers_returns_false(): def test_get_view_exceptions_location_message_returns_expected_message(): actual = get_view_error_details_message() path = os.path.join(get_user_project_path("log"), "code42_errors.log") - expected = "View details in {}".format(path) + expected = f"View details in {path}" assert actual == expected diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 6f7c56281..168682f53 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -9,7 +9,7 @@ from code42cli.bulk import run_bulk_process from code42cli.logger import get_view_error_details_message -_NAMESPACE = "{}.bulk".format(PRODUCT_NAME) +_NAMESPACE = f"{PRODUCT_NAME}.bulk" @pytest.fixture @@ -19,7 +19,7 @@ def bulk_processor(mocker): @pytest.fixture def bulk_processor_factory(mocker, bulk_processor): - mock_factory = mocker.patch("{}._create_bulk_processor".format(_NAMESPACE)) + mock_factory = mocker.patch(f"{_NAMESPACE}._create_bulk_processor") mock_factory.return_value = bulk_processor return mock_factory diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index e03911cf9..9bd723304 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -263,12 +263,12 @@ def test_to_table_when_not_given_header_creates_header_dynamically(): def test_to_json(): formatted_output = output_formats_module.to_json(TEST_DATA) - assert formatted_output == "{}\n".format(json.dumps(TEST_DATA)) + assert formatted_output == f"{json.dumps(TEST_DATA)}\n" def test_to_formatted_json(): formatted_output = output_formats_module.to_formatted_json(TEST_DATA) - assert formatted_output == "{}\n".format(json.dumps(TEST_DATA, indent=4)) + assert formatted_output == f"{json.dumps(TEST_DATA, indent=4)}\n" class TestOutputFormatter: @@ -753,7 +753,7 @@ def get_cef_parts(cef_str): def key_value_pair_in_cef_extension(field_name, field_value, cef_str): cef_parts = get_cef_parts(cef_str) - kvp = "{}={}".format(field_name, field_value) + kvp = f"{field_name}={field_value}" return kvp in cef_parts[-1] diff --git a/tests/test_password.py b/tests/test_password.py index 22808bfcd..203a0727f 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,7 +1,6 @@ import pytest import code42cli.password as password -from code42cli import PRODUCT_NAME _USERNAME = "test.username" @@ -25,19 +24,19 @@ def get_keyring(mocker): @pytest.fixture def getpass_function(mocker): - return mocker.patch("{}.password.getpass".format(PRODUCT_NAME)) + return mocker.patch("code42cli.password.getpass") @pytest.fixture def user_agreement(mocker): - mock = mocker.patch("{}.password.does_user_agree".format(PRODUCT_NAME)) + mock = mocker.patch("code42cli.password.does_user_agree") mock.return_value = True return mocker @pytest.fixture def user_disagreement(mocker): - mock = mocker.patch("{}.password.does_user_agree".format(PRODUCT_NAME)) + mock = mocker.patch("code42cli.password.does_user_agree") mock.return_value = False return mocker @@ -47,7 +46,7 @@ def test_get_stored_password_when_given_profile_name_gets_profile_for_that_name( ): profile.name = "foo" profile.username = "bar" - service_name = "{}::{}".format(PRODUCT_NAME, profile.name) + service_name = f"code42cli::{profile.name}" password.get_stored_password(profile) keyring_password_getter.assert_called_once_with(service_name, profile.username) diff --git a/tests/test_profile.py b/tests/test_profile.py index b587cd7c9..ce22c6992 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -3,7 +3,6 @@ import code42cli.profile as cliprofile from .conftest import create_mock_profile from .conftest import MockSection -from code42cli import PRODUCT_NAME from code42cli.cmds.search.cursor_store import AlertCursorStore from code42cli.cmds.search.cursor_store import AuditLogCursorStore from code42cli.cmds.search.cursor_store import FileEventCursorStore @@ -15,18 +14,18 @@ @pytest.fixture def config_accessor(mocker): mock = mocker.MagicMock(spec=ConfigAccessor, name="Config Accessor") - attr = mocker.patch("{}.profile.config_accessor".format(PRODUCT_NAME), mock) + attr = mocker.patch("code42cli.profile.config_accessor", mock) return attr @pytest.fixture def password_setter(mocker): - return mocker.patch("{}.password.set_password".format(PRODUCT_NAME)) + return mocker.patch("code42cli.password.set_password") @pytest.fixture def password_getter(mocker): - return mocker.patch("{}.password.get_stored_password".format(PRODUCT_NAME)) + return mocker.patch("code42cli.password.get_stored_password") @pytest.fixture @@ -39,9 +38,7 @@ def test_get_password_when_is_none_returns_password_from_getpass( self, mocker, password_getter ): password_getter.return_value = None - mock_getpass = mocker.patch( - "{}.password.get_password_from_prompt".format(PRODUCT_NAME) - ) + mock_getpass = mocker.patch("code42cli.password.get_password_from_prompt") mock_getpass.return_value = "Test Password" actual = create_mock_profile().get_password() assert actual == "Test Password" diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 836065143..f7f8b75db 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -56,10 +56,8 @@ def test_create_sdk_when_profile_has_ssl_errors_disabled_sets_py42_setting_and_p output = capsys.readouterr() assert not mock_py42.settings.verify_ssl_certs assert ( - "Warning: Profile '{}' has SSL verification disabled. Adding certificate verification is strongly advised.".format( - profile.name - ) - in output.err + f"Warning: Profile '{profile.name}' has SSL verification disabled. Adding certificate " + "verification is strongly advised." in output.err ) diff --git a/tests/test_util.py b/tests/test_util.py index 274c9e2c2..0241aa957 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,6 +1,5 @@ import pytest -from code42cli import PRODUCT_NAME from code42cli.util import _PADDING_SIZE from code42cli.util import does_user_agree from code42cli.util import find_format_width @@ -31,7 +30,7 @@ def echo_output(mocker): return mocker.patch("code42cli.util.echo") -_NAMESPACE = "{}.util".format(PRODUCT_NAME) +_NAMESPACE = "code42cli.util" def get_expected_row_width(max_col_len, max_width): From c9f925a0ebf5e53230b5a2ed82ef0e5ff0a337c7 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Tue, 15 Jun 2021 15:46:28 -0500 Subject: [PATCH 247/349] auto console w sdk (#293) --- CHANGELOG.md | 2 ++ setup.py | 1 + src/code42cli/__init__.py | 13 +++++++++++++ src/code42cli/cmds/shell.py | 12 ++++++++++++ src/code42cli/main.py | 22 +++++++--------------- 5 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 src/code42cli/cmds/shell.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d18fa71d..ffa21dbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - New command `code42 users remove-role` to remove a user role from a single user. +- New command `code42 shell` that opens an IPython console with a pre-initialized py42 sdk. + ## 1.6.1 - 2021-05-27 ### Fixed diff --git a/setup.py b/setup.py index 7fa8995b6..a2a19918c 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ "c42eventextractor==0.4.1", "keyring==18.0.1", "keyrings.alt==3.2.0", + "ipython>=7.16.1", "pandas>=1.1.3", "py42>=1.14.2", ], diff --git a/src/code42cli/__init__.py b/src/code42cli/__init__.py index 7cbf87816..a0a7d79b2 100644 --- a/src/code42cli/__init__.py +++ b/src/code42cli/__init__.py @@ -1,2 +1,15 @@ +from py42.__version__ import __version__ as py42version + +from code42cli.__version__ import __version__ as cliversion + + PRODUCT_NAME = "code42cli" MAIN_COMMAND = "code42" +BANNER = f"""\b + dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. +dP `" dP Yb 8I Yb 88__ dP 88 "' dP' +Yb Yb dP 8I dY 88"" d888888 dP' + YboodP YbodP 8888Y" 888888 88 .d8888 + +code42cli version {cliversion}, by Code42 Software. +powered by py42 version {py42version}.""" diff --git a/src/code42cli/cmds/shell.py b/src/code42cli/cmds/shell.py new file mode 100644 index 000000000..bd33d681a --- /dev/null +++ b/src/code42cli/cmds/shell.py @@ -0,0 +1,12 @@ +import click +import IPython + +from code42cli import BANNER +from code42cli.options import sdk_options + + +@click.command() +@sdk_options() +def shell(state): + """Open an IPython shell with py42 initialized as `sdk`.""" + IPython.embed(colors="Neutral", banner1=BANNER, user_ns={"sdk": state.sdk}) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 5e2a61274..0dcf04197 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -4,11 +4,10 @@ import click from click_plugins import with_plugins from pkg_resources import iter_entry_points -from py42.__version__ import __version__ as py42version from py42.settings import set_user_agent_suffix +from code42cli import BANNER from code42cli import PRODUCT_NAME -from code42cli.__version__ import __version__ as cliversion from code42cli.click_ext.groups import ExceptionHandlingGroup from code42cli.cmds.alert_rules import alert_rules from code42cli.cmds.alerts import alerts @@ -20,18 +19,10 @@ from code42cli.cmds.legal_hold import legal_hold from code42cli.cmds.profile import profile from code42cli.cmds.securitydata import security_data +from code42cli.cmds.shell import shell from code42cli.cmds.users import users from code42cli.options import sdk_options -BANNER = f"""\b - dP""b8 dP"Yb 8888b. 888888 dP88 oP"Yb. -dP `" dP Yb 8I Yb 88__ dP 88 "' dP' -Yb Yb dP 8I dY 88"" d888888 dP' - YboodP YbodP 8888Y" 888888 88 .d8888 - -code42cli version {cliversion}, by Code42 Software. -powered by py42 version {py42version}.""" - # Handle KeyboardInterrupts by just exiting instead of printing out a stack def exit_on_interrupt(signal, frame): @@ -73,12 +64,13 @@ def cli(state, python): cli.add_command(alerts) cli.add_command(alert_rules) -cli.add_command(security_data) +cli.add_command(audit_logs) +cli.add_command(cases) cli.add_command(departing_employee) +cli.add_command(devices) cli.add_command(high_risk_employee) cli.add_command(legal_hold) cli.add_command(profile) -cli.add_command(devices) +cli.add_command(security_data) +cli.add_command(shell) cli.add_command(users) -cli.add_command(audit_logs) -cli.add_command(cases) From fde4a70d4810923b668e8ca2d8d00af75c567dd1 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 17 Jun 2021 09:10:32 -0500 Subject: [PATCH 248/349] release prep (#294) --- CHANGELOG.md | 2 +- setup.py | 2 +- src/code42cli/__version__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa21dbef..6fbc5c72c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.7.0 - 2021-06-17 ### Added diff --git a/setup.py b/setup.py index a2a19918c..5cae9ae6c 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython>=7.16.1", "pandas>=1.1.3", - "py42>=1.14.2", + "py42>=1.15.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index f49459c74..14d9d2f58 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.6.1" +__version__ = "1.7.0" From f760f0ebc57106e5847582431612b93ea9785002 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 17 Jun 2021 11:26:09 -0500 Subject: [PATCH 249/349] switch to main (#295) --- .github/workflows/build.yml | 2 +- .github/workflows/cla.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/style.yml | 2 +- README.md | 2 +- docs/userguides/gettingstarted.md | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be5dfa44e..72e1f604f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ name: build on: push: branches: - - master + - main tags: - v* pull_request: diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 156f39f33..576255111 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -21,7 +21,7 @@ jobs: path-to-signatures: '.cla_signatures.json' path-to-cla-document: 'https://code42.github.io/code42-cla/Code42_Individual_Contributor_License_Agreement' # branch should not be protected - branch: 'master' + branch: 'main' allowlist: alang13,unparalleled-js,kiran-chaudhary,timabrmsn,ceciliastevens,DiscoRiver,annie-payseur,amoravec,patelsagar192 #below are the optional inputs - If the optional inputs are not given, then default values will be taken diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b3026b14a..4afff063f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,7 +3,7 @@ name: docs on: push: branches: - - master + - main tags: - v* pull_request: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 7208ee10b..fd50ef5c9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -32,7 +32,7 @@ jobs: - name: Run Unit tests env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock - run: tox -e nightly # Run tox using latest master branch from py42/c42eventextractor + run: tox -e nightly # Run tox using latest main branch from py42/c42eventextractor - name: Notify Slack Action uses: 8398a7/action-slack@v3 with: diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index dc2b822dd..dfc4dfcb7 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -3,7 +3,7 @@ name: style on: push: branches: - - master + - main tags: - v* pull_request: diff --git a/README.md b/README.md index 70e4d4ee4..74b627ac2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # The Code42 CLI ![Build status](https://github.com/code42/code42cli/workflows/build/badge.svg) -[![codecov.io](https://codecov.io/github/code42/code42cli/coverage.svg?branch=master)](https://codecov.io/github/code42/code42cli?branch=master) +[![codecov.io](https://codecov.io/github/code42/code42cli/coverage.svg?branch=main)](https://codecov.io/github/code42/code42cli?branch=master) [![versions](https://img.shields.io/pypi/pyversions/code42cli.svg)](https://pypi.org/project/code42cli/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Documentation Status](https://readthedocs.org/projects/code42cli/badge/?version=latest)](https://clidocs.code42.com/en/latest/?badge=latest) diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index 58925bac4..4669d54ad 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -7,7 +7,7 @@ ## Licensing -This project uses the [MIT License](https://github.com/code42/code42cli/blob/master/LICENSE.md). +This project uses the [MIT License](https://github.com/code42/code42cli/blob/main/LICENSE.md). ## Installation From bc55ce3b4c54fd7193688073172058e19b1b0a67 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 17 Jun 2021 15:52:38 -0500 Subject: [PATCH 250/349] put back master (#296) --- .github/workflows/cla.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 576255111..156f39f33 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -21,7 +21,7 @@ jobs: path-to-signatures: '.cla_signatures.json' path-to-cla-document: 'https://code42.github.io/code42-cla/Code42_Individual_Contributor_License_Agreement' # branch should not be protected - branch: 'main' + branch: 'master' allowlist: alang13,unparalleled-js,kiran-chaudhary,timabrmsn,ceciliastevens,DiscoRiver,annie-payseur,amoravec,patelsagar192 #below are the optional inputs - If the optional inputs are not given, then default values will be taken From f888bf31348255625c19003842b39291741bf5ce Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 18 Jun 2021 10:21:04 -0500 Subject: [PATCH 251/349] back to main (#297) --- .github/workflows/cla.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 156f39f33..576255111 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -21,7 +21,7 @@ jobs: path-to-signatures: '.cla_signatures.json' path-to-cla-document: 'https://code42.github.io/code42-cla/Code42_Individual_Contributor_License_Agreement' # branch should not be protected - branch: 'master' + branch: 'main' allowlist: alang13,unparalleled-js,kiran-chaudhary,timabrmsn,ceciliastevens,DiscoRiver,annie-payseur,amoravec,patelsagar192 #below are the optional inputs - If the optional inputs are not given, then default values will be taken From 1e606b9d32cd58d5f19fbeaf72db8eb28c5473c9 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Wed, 23 Jun 2021 09:28:42 -0500 Subject: [PATCH 252/349] Query Alerts with Microsecond precision (#298) --- CHANGELOG.md | 6 ++++++ setup.py | 2 +- src/code42cli/date_helper.py | 2 +- tests/cmds/test_alerts.py | 22 +++++++++++----------- tests/test_magic_date_type.py | 4 ++-- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fbc5c72c..1dbe0940f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Changed + +- `code42 alerts search` now uses microsecond precision when searching by alerts' date observed. + ## 1.7.0 - 2021-06-17 ### Added diff --git a/setup.py b/setup.py index 5cae9ae6c..d57c4a1fe 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython>=7.16.1", "pandas>=1.1.3", - "py42>=1.15.0", + "py42>=1.15.1", ], extras_require={ "dev": [ diff --git a/src/code42cli/date_helper.py b/src/code42cli/date_helper.py index fef52e837..95510afe8 100644 --- a/src/code42cli/date_helper.py +++ b/src/code42cli/date_helper.py @@ -48,4 +48,4 @@ def round_datetime_to_day_start(dt): def round_datetime_to_day_end(dt): - return dt.replace(hour=23, minute=59, second=59, microsecond=999000) + return dt.replace(hour=23, minute=59, second=59, microsecond=999999) diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 2cb52c9b5..9e49f4e58 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -114,9 +114,9 @@ "state_2": "PENDING", "state_3": "IN_PROGRESS", "actor": "test@example.com", - "on_or_after": "2020-01-01T06:00:00.000Z", + "on_or_after": "2020-01-01T06:00:00.000000Z", "on_or_after_timestamp": 1577858400.0, - "on_or_before": "2020-02-01T06:00:00.000Z", + "on_or_before": "2020-02-01T06:00:00.000000Z", "on_or_before_timestamp": 1580536800.0, "rule_id": "xyz123", } @@ -328,7 +328,7 @@ def alert_cursor_without_checkpoint(mocker): def begin_option(mocker): mock = mocker.patch(f"{PRODUCT_NAME}.cmds.alerts.convert_datetime_to_timestamp") mock.return_value = BEGIN_TIMESTAMP - mock.expected_timestamp = "2020-01-01T06:00:00.000Z" + mock.expected_timestamp = "2020-01-01T06:00:00.000000Z" return mock @@ -428,9 +428,9 @@ def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( ) filters = alert_extractor.extract.call_args[0][0] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = f"{begin_date}T00:00:00.000Z" + expected_begin = f"{begin_date}T00:00:00.000000Z" actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = f"{end_date}T23:59:59.999Z" + expected_end = f"{end_date}T23:59:59.999999Z" assert actual_begin == expected_begin assert actual_end == expected_end @@ -449,9 +449,9 @@ def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( ) filters = alert_extractor.extract.call_args[0][0] actual_begin = get_filter_value_from_json(filters, filter_index=0) - expected_begin = f"{begin_date}T{time}.000Z" + expected_begin = f"{begin_date}T{time}.000000Z" actual_end = get_filter_value_from_json(filters, filter_index=1) - expected_end = f"{end_date}T{time}.000Z" + expected_end = f"{end_date}T{time}.000000Z" assert actual_begin == expected_begin assert actual_end == expected_end @@ -466,7 +466,7 @@ def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_que actual = get_filter_value_from_json( alert_extractor.extract.call_args[0][0], filter_index=0 ) - expected = f"{date}T{time}:00.000Z" + expected = f"{date}T{time}:00.000000Z" assert actual == expected @@ -485,7 +485,7 @@ def test_search_and_send_to_when_given_end_date_and_time_uses_expected_query( actual = get_filter_value_from_json( alert_extractor.extract.call_args[0][0], filter_index=1 ) - expected = f"{end_date}T{time}:00.000Z" + expected = f"{end_date}T{time}:00.000000Z" assert actual == expected @@ -521,7 +521,7 @@ def test_search_and_send_to_when_given_begin_date_and_not_use_checkpoint_and_cur actual_ts = get_filter_value_from_json( alert_extractor.extract.call_args[0][0], filter_index=0 ) - expected_ts = f"{begin_date}T00:00:00.000Z" + expected_ts = f"{begin_date}T00:00:00.000000Z" assert actual_ts == expected_ts assert filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) @@ -768,7 +768,7 @@ def test_search_and_send_to_with_or_query_flag_produces_expected_query( { "operator": "ON_OR_AFTER", "term": "createdAt", - "value": f"{begin_date}T00:00:00.000Z", + "value": f"{begin_date}T00:00:00.000000Z", } ], }, diff --git a/tests/test_magic_date_type.py b/tests/test_magic_date_type.py index 0919582e7..86a8446f4 100644 --- a/tests/test_magic_date_type.py +++ b/tests/test_magic_date_type.py @@ -117,7 +117,7 @@ def test_when_given_date_str_parses_successfully(self): actual = self.convert(end_date_str) expected = datetime.strptime(end_date_str, "%Y-%m-%d") expected = utc( - expected.replace(hour=23, minute=59, second=59, microsecond=999000) + expected.replace(hour=23, minute=59, second=59, microsecond=999999) ) assert actual == expected @@ -130,7 +130,7 @@ def test_when_given_magic_days_parses_successfully(self): actual_date = self.convert("20d") expected_date = get_test_date(days_ago=20) expected_date = utc( - expected_date.replace(hour=23, minute=59, second=59, microsecond=999000) + expected_date.replace(hour=23, minute=59, second=59, microsecond=999999) ) assert actual_date == expected_date From b2d1f44b4caed946b894a91ec32c15b8e86ce2e4 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Fri, 25 Jun 2021 11:34:39 -0500 Subject: [PATCH 253/349] Update user (#300) --- CHANGELOG.md | 6 ++ src/code42cli/click_ext/groups.py | 2 + src/code42cli/cmds/devices.py | 2 +- src/code42cli/cmds/users.py | 121 +++++++++++++++++++++++++++++ tests/cmds/test_devices.py | 2 +- tests/cmds/test_users.py | 125 ++++++++++++++++++++++++++++++ 6 files changed, 256 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dbe0940f..b74e6693a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Added + +- New command `code42 users update` to update a single user. + +- New command `code42 users bulk update` to update users in bulk. + ### Changed - `code42 alerts search` now uses microsecond precision when searching by alerts' date observed. diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index ec0bd2fb4..38d3b6b48 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -12,6 +12,7 @@ from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError from py42.exceptions import Py42UpdateClosedCaseError from py42.exceptions import Py42UserAlreadyAddedError +from py42.exceptions import Py42UsernameMustBeEmailError from py42.exceptions import Py42UserNotOnListError from code42cli.errors import Code42CLIError @@ -67,6 +68,7 @@ def invoke(self, ctx): Py42DescriptionLimitExceededError, Py42CaseAlreadyHasEventError, Py42UpdateClosedCaseError, + Py42UsernameMustBeEmailError, ) as err: self.logger.log_error(err) raise Code42CLIError(str(err)) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index a86ce9fd8..92e3fa0eb 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -573,7 +573,7 @@ def bulk(state): def bulk_deactivate(state, csv_rows, change_device_name, purge_date, format): """Deactivate all devices from the provided CSV containing a 'guid' column.""" sdk = state.sdk - csv_rows[0]["deactivated"] = False + csv_rows[0]["deactivated"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) for row in csv_rows: row["change_device_name"] = change_device_name diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index d368dcca6..217b24ca7 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -1,14 +1,18 @@ import click from pandas import DataFrame +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process from code42cli.click_ext.groups import OrderedGroup from code42cli.click_ext.options import incompatible_with from code42cli.errors import Code42CLIError from code42cli.errors import UserDoesNotExistError +from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import DataFrameOutputFormatter from code42cli.output_formats import OutputFormat +from code42cli.output_formats import OutputFormatter @click.group(cls=OrderedGroup) @@ -33,6 +37,11 @@ def users(state): ) +user_uid_option = click.option( + "--user-id", help="The unique identifier of the user to be modified.", required=True +) + + def role_name_option(help): return click.option("--role-name", help=help) @@ -84,6 +93,95 @@ def remove_role(state, username, role_name): _remove_user_role(state.sdk, role_name, username) +@users.command(name="update") +@user_uid_option +@click.option("--username", help="The new username for the user.") +@click.option("--password", help="The new password for the user.") +@click.option("--email", help="The new email for the user.") +@click.option("--first-name", help="The new first name for the user.") +@click.option("--last-name", help="The new last name for the user.") +@click.option("--notes", help="Notes about this user.") +@click.option( + "--archive-size-quota", help="The total size (in bytes) allowed for this user." +) +@sdk_options() +def update_user( + state, + user_id, + username, + email, + password, + first_name, + last_name, + notes, + archive_size_quota, +): + """Update a user with the specified unique identifier.""" + _update_user( + state.sdk, + user_id, + username, + email, + password, + first_name, + last_name, + notes, + archive_size_quota, + ) + + +_bulk_user_update_headers = [ + "user_id", + "username", + "email", + "password", + "first_name", + "last_name", + "notes", + "archive_size_quota", +] + + +@users.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def bulk(state): + """Tools for managing users in bulk""" + pass + + +users_generate_template = generate_template_cmd_factory( + group_name="users", + commands_dict={"update": _bulk_user_update_headers}, + help_message="Generate the CSV template needed for bulk user commands.", +) +bulk.add_command(users_generate_template) + + +@bulk.command(name="update") +@read_csv_arg(headers=_bulk_user_update_headers) +@format_option +@sdk_options() +def bulk_update(state, csv_rows, format): + """Update a list of users from the provided CSV.""" + csv_rows[0]["updated"] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + + def handle_row(**row): + try: + _update_user( + state.sdk, **{key: row[key] for key in row.keys() if key != "updated"} + ) + row["updated"] = "True" + except Exception as err: + row["updated"] = f"False: {err}" + return row + + result_rows = run_bulk_process( + handle_row, csv_rows, progress_label="Updating users:" + ) + formatter.echo_formatted_list(result_rows) + + def _add_user_role(sdk, username, role_name): user_id = _get_user_id(sdk, username) _get_role_id(sdk, role_name) # function provides role name validation @@ -122,3 +220,26 @@ def _get_users_dataframe(sdk, columns, org_uid, role_id, active): users_list.extend(page["users"]) return DataFrame.from_records(users_list, columns=columns) + + +def _update_user( + sdk, + user_id, + username, + email, + password, + first_name, + last_name, + notes, + archive_size_quota, +): + return sdk.users.update_user( + user_id, + username=username, + email=email, + password=password, + first_name=first_name, + last_name=last_name, + notes=notes, + archive_size_quota_bytes=archive_size_quota, + ) diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index bb10618e9..edce17ebe 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -822,7 +822,7 @@ def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state): assert bulk_processor.call_args[0][1] == [ { "guid": "test", - "deactivated": False, + "deactivated": "False", "change_device_name": False, "purge_date": None, } diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 506fbde76..8cc66802f 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -7,6 +7,8 @@ from code42cli.main import cli +_NAMESPACE = "code42cli.cmds.users" + TEST_ROLE_RETURN_DATA = { "data": [{"roleName": "Customer Cloud Admin", "roleId": "1234543"}] } @@ -50,6 +52,11 @@ def get_all_users_generator(): yield TEST_USERS_RESPONSE +@pytest.fixture +def update_user_response(mocker): + return _create_py42_response(mocker, "") + + @pytest.fixture def get_available_roles_response(mocker): return _create_py42_response(mocker, json.dumps(TEST_ROLE_RETURN_DATA)) @@ -75,6 +82,11 @@ def get_available_roles_success(cli_state, get_available_roles_response): cli_state.sdk.users.get_available_roles.return_value = get_available_roles_response +@pytest.fixture +def update_user_success(cli_state, update_user_response): + cli_state.sdk.users.update_user.return_value = update_user_response + + def test_list_when_non_table_format_outputs_expected_columns( runner, cli_state, get_all_users_success ): @@ -263,3 +275,116 @@ def test_remove_user_role_raises_error_when_username_does_not_exist( result = runner.invoke(cli, command, obj=cli_state) assert result.exit_code == 1 assert "User 'not_a_username@example.com' does not exist." in result.output + + +def test_update_user_calls_update_user_with_correct_parameters_when_only_some_are_passed( + runner, cli_state, update_user_success +): + command = ["users", "update", "--user-id", "12345", "--email", "test_email"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.update_user.assert_called_once_with( + "12345", + username=None, + email="test_email", + password=None, + first_name=None, + last_name=None, + notes=None, + archive_size_quota_bytes=None, + ) + + +def test_update_user_calls_update_user_with_correct_parameters_when_all_are_passed( + runner, cli_state, update_user_success +): + command = [ + "users", + "update", + "--user-id", + "12345", + "--email", + "test_email", + "--username", + "test_username", + "--password", + "test_password", + "--first-name", + "test_fname", + "--last-name", + "test_lname", + "--notes", + "test notes", + "--archive-size-quota", + "123456", + ] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.update_user.assert_called_once_with( + "12345", + username="test_username", + email="test_email", + password="test_password", + first_name="test_fname", + last_name="test_lname", + notes="test notes", + archive_size_quota_bytes="123456", + ) + + +def test_bulk_deactivate_uses_expected_arguments_when_only_some_are_passed( + runner, mocker, cli_state +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_update.csv", "w") as csv: + csv.writelines( + [ + "user_id,username,email,password,first_name,last_name,notes,archive_size_quota\n", + "12345,,test_email,,,,,\n", + ] + ) + runner.invoke( + cli, ["users", "bulk", "update", "test_bulk_update.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [ + { + "user_id": "12345", + "username": "", + "email": "test_email", + "password": "", + "first_name": "", + "last_name": "", + "notes": "", + "archive_size_quota": "", + "updated": "False", + } + ] + + +def test_bulk_deactivate_uses_expected_arguments_when_all_are_passed( + runner, mocker, cli_state +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_update.csv", "w") as csv: + csv.writelines( + [ + "user_id,username,email,password,first_name,last_name,notes,archive_size_quota\n", + "12345,test_username,test_email,test_pword,test_fname,test_lname,test notes,4321\n", + ] + ) + runner.invoke( + cli, ["users", "bulk", "update", "test_bulk_update.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [ + { + "user_id": "12345", + "username": "test_username", + "email": "test_email", + "password": "test_pword", + "first_name": "test_fname", + "last_name": "test_lname", + "notes": "test notes", + "archive_size_quota": "4321", + "updated": "False", + } + ] From 89b3df08efdfe5a4faf93e91771890becf5d9821 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Mon, 28 Jun 2021 07:18:46 -0500 Subject: [PATCH 254/349] add move and bulk move methods (#301) --- CHANGELOG.md | 4 +++ src/code42cli/cmds/users.py | 55 +++++++++++++++++++++++++++++++++++-- tests/cmds/test_users.py | 34 +++++++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b74e6693a..149540e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - New command `code42 users bulk update` to update users in bulk. +- New command `code42 users move` to move a single user to a different organization. + +- New command `code42 users bulk move` to move users in bulk. + ### Changed - `code42 alerts search` now uses microsecond precision when searching by alerts' date observed. diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 217b24ca7..4518a9130 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -41,13 +41,20 @@ def users(state): "--user-id", help="The unique identifier of the user to be modified.", required=True ) +org_id_option = click.option( + "--org-id", + help="The identifier for the organization to which the user will be moved.", + required=True, + type=int, +) + def role_name_option(help): return click.option("--role-name", help=help) -def username_option(help): - return click.option("--username", help=help) +def username_option(help, required=False): + return click.option("--username", help=help, required=required) @users.command(name="list") @@ -141,6 +148,17 @@ def update_user( "archive_size_quota", ] +_bulk_user_move_headers = ["username", "org_id"] + + +@users.command(name="move") +@username_option("The username of the user to move.", required=True) +@org_id_option +@sdk_options() +def change_organization(state, username, org_id): + """Change the organization of the user with the given username to the org with the given org ID.""" + _change_organization(state.sdk, username, org_id) + @users.group(cls=OrderedGroup) @sdk_options(hidden=True) @@ -151,7 +169,10 @@ def bulk(state): users_generate_template = generate_template_cmd_factory( group_name="users", - commands_dict={"update": _bulk_user_update_headers}, + commands_dict={ + "update": _bulk_user_update_headers, + "move": _bulk_user_move_headers, + }, help_message="Generate the CSV template needed for bulk user commands.", ) bulk.add_command(users_generate_template) @@ -182,6 +203,29 @@ def handle_row(**row): formatter.echo_formatted_list(result_rows) +@bulk.command(name="move") +@read_csv_arg(headers=_bulk_user_move_headers) +@format_option +@sdk_options() +def bulk_move(state, csv_rows, format): + """Change the organization of the list of users from the provided CSV.""" + csv_rows[0]["moved"] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + + def handle_row(**row): + try: + _change_organization( + state.sdk, **{key: row[key] for key in row.keys() if key != "moved"} + ) + row["moved"] = "True" + except Exception as err: + row["moved"] = f"False: {err}" + return row + + result_rows = run_bulk_process(handle_row, csv_rows, progress_label="Moving users:") + formatter.echo_formatted_list(result_rows) + + def _add_user_role(sdk, username, role_name): user_id = _get_user_id(sdk, username) _get_role_id(sdk, role_name) # function provides role name validation @@ -243,3 +287,8 @@ def _update_user( notes=notes, archive_size_quota_bytes=archive_size_quota, ) + + +def _change_organization(sdk, username, org_id): + user_id = _get_user_id(sdk, username) + return sdk.users.change_org_assignment(user_id=int(user_id), org_id=int(org_id)) diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 8cc66802f..9427dd363 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -62,6 +62,11 @@ def get_available_roles_response(mocker): return _create_py42_response(mocker, json.dumps(TEST_ROLE_RETURN_DATA)) +@pytest.fixture +def change_org_response(mocker): + return _create_py42_response(mocker, "") + + @pytest.fixture def get_all_users_success(cli_state): cli_state.sdk.users.get_all.return_value = get_all_users_generator() @@ -87,6 +92,11 @@ def update_user_success(cli_state, update_user_response): cli_state.sdk.users.update_user.return_value = update_user_response +@pytest.fixture +def change_org_success(cli_state, change_org_response): + cli_state.sdk.users.change_org_assignment.return_value = change_org_response + + def test_list_when_non_table_format_outputs_expected_columns( runner, cli_state, get_all_users_success ): @@ -388,3 +398,27 @@ def test_bulk_deactivate_uses_expected_arguments_when_all_are_passed( "updated": "False", } ] + + +def test_change_org_calls_change_org_assignment_with_correct_parameters( + runner, cli_state, change_org_success, get_user_id_success +): + command = ["users", "move", "--username", TEST_USERNAME, "--org-id", "54321"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.change_org_assignment.assert_called_once_with( + user_id=TEST_USER_ID, org_id=54321 + ) + + +def test_bulk_move_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_move.csv", "w") as csv: + csv.writelines(["username,org_id\n", f"{TEST_USERNAME},4321\n"]) + runner.invoke( + cli, ["users", "bulk", "move", "test_bulk_move.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "org_id": "4321", "moved": "False"} + ] + bulk_processor.assert_called_once() From f8a4c334dadded7ba741b995333b963d72dab517 Mon Sep 17 00:00:00 2001 From: Peter Briggs Date: Wed, 30 Jun 2021 11:57:08 -0500 Subject: [PATCH 255/349] Add files for code ownership and classification (#302) * Add files for code ownership and classification * Change owner listing to Code42 Github team --- CODECLASSIFICATION | 7 +++++++ CODEOWNERS | 1 + 2 files changed, 8 insertions(+) create mode 100644 CODECLASSIFICATION create mode 100644 CODEOWNERS diff --git a/CODECLASSIFICATION b/CODECLASSIFICATION new file mode 100644 index 000000000..a2edc7299 --- /dev/null +++ b/CODECLASSIFICATION @@ -0,0 +1,7 @@ +# CODECLASSIFICATION for code42cli + +# Specify all repository branches as non-production (catch all) +/refs/heads/* non-prod + +# Specify the 'main' branch as the only production branch +/refs/heads/main production diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..539130336 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @code42/literally-skynet From 30540ffae923f08f894caab0a45f74018243edfa Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 1 Jul 2021 14:50:55 -0500 Subject: [PATCH 256/349] Fix 2 Bulk Bugs in the CLI (addressing QA feedback) (#303) --- CHANGELOG.md | 5 + src/code42cli/bulk.py | 49 +++++++-- src/code42cli/cmds/devices.py | 19 +++- src/code42cli/cmds/users.py | 43 ++++++-- src/code42cli/worker.py | 10 +- tests/cmds/test_devices.py | 101 ++++++++++++++++- tests/cmds/test_users.py | 201 ++++++++++++++++++++++++++++++++-- tests/test_bulk.py | 4 +- 8 files changed, 397 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 149540e79..df168e7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Fixed + +- Issue where `code42 devices bulk deactivate` and `code42 devices bulk reactivate` would + output incorrect Successes and Failures at the end of the process. + ### Added - New command `code42 users update` to update a single user. diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index 39c5f5ef5..db17336c9 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -66,7 +66,9 @@ def generate_template(cmd, path): return generate_template -def run_bulk_process(row_handler, rows, progress_label=None): +def run_bulk_process( + row_handler, rows, progress_label=None, stats=None, raise_global_error=True +): """Runs a bulk process. Args: @@ -74,14 +76,34 @@ def run_bulk_process(row_handler, rows, progress_label=None): either *args or **kwargs. rows (iterable): the rows to process. progress_label: a label that prints with the progress bar. + stats (WorkerStats): Pass in WorkerStats if doing error handling outside of the worker. + raise_global_error (bool): Set to False to *NOT* raise a CLI error if any rows fail. + This is useful if doing error handling outside of the worker class. + + Returns: + :class:`WorkerStats`: A class containing the successes and failures count. """ - processor = _create_bulk_processor(row_handler, rows, progress_label) + processor = _create_bulk_processor( + row_handler, + rows, + progress_label, + stats=stats, + raise_global_error=raise_global_error, + ) return processor.run() -def _create_bulk_processor(row_handler, rows, progress_label): +def _create_bulk_processor( + row_handler, rows, progress_label, stats=None, raise_global_error=True +): """A factory method to create the bulk processor, useful for testing purposes.""" - return BulkProcessor(row_handler, rows, progress_label=progress_label) + return BulkProcessor( + row_handler, + rows, + progress_label=progress_label, + stats=stats, + raise_global_error=raise_global_error, + ) class BulkProcessor: @@ -95,7 +117,15 @@ class BulkProcessor: `row_handler` only needs to take an extra arg. """ - def __init__(self, row_handler, rows, worker=None, progress_label=None): + def __init__( + self, + row_handler, + rows, + worker=None, + progress_label=None, + stats=None, + raise_global_error=True, + ): total = len(rows) self._rows = rows self._row_handler = row_handler @@ -104,7 +134,8 @@ def __init__(self, row_handler, rows, worker=None, progress_label=None): item_show_func=self._show_stats, label=progress_label, ) - self.__worker = worker or Worker(5, total, bar=self._progress_bar) + self._raise_global_error = raise_global_error + self.__worker = worker or Worker(5, total, bar=self._progress_bar, stats=stats) self._stats = self.__worker.stats def run(self): @@ -113,7 +144,7 @@ def run(self): for row in self._rows: self._process_row(row) self.__worker.wait() - self._print_results() + self._handle_if_errors() return self._stats._results def _process_row(self, row): @@ -144,7 +175,7 @@ def _handle_row(self, *args, **kwargs): def _show_stats(self, _): return str(self._stats) - def _print_results(self): + def _handle_if_errors(self): click.echo("") - if self._stats.total_errors: + if self._stats.total_errors and self._raise_global_error: raise LoggedCLIError("Some problems occurred during bulk processing.") diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 92e3fa0eb..6de1649e7 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -24,6 +24,7 @@ from code42cli.output_formats import DataFrameOutputFormatter from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter +from code42cli.worker import create_worker_stats @click.group(cls=OrderedGroup) @@ -575,6 +576,7 @@ def bulk_deactivate(state, csv_rows, change_device_name, purge_date, format): sdk = state.sdk csv_rows[0]["deactivated"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) for row in csv_rows: row["change_device_name"] = change_device_name row["purge_date"] = purge_date @@ -587,10 +589,15 @@ def handle_row(**row): row["deactivated"] = "True" except Exception as err: row["deactivated"] = f"False: {err}" + stats.increment_total_errors() return row result_rows = run_bulk_process( - handle_row, csv_rows, progress_label="Deactivating devices:" + handle_row, + csv_rows, + progress_label="Deactivating devices:", + stats=stats, + raise_global_error=False, ) formatter.echo_formatted_list(result_rows) @@ -602,8 +609,9 @@ def handle_row(**row): def bulk_reactivate(state, csv_rows, format): """Reactivate all devices from the provided CSV containing a 'guid' column.""" sdk = state.sdk - csv_rows[0]["reactivated"] = False + csv_rows[0]["reactivated"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) def handle_row(**row): try: @@ -611,9 +619,14 @@ def handle_row(**row): row["reactivated"] = "True" except Exception as err: row["reactivated"] = f"False: {err}" + stats.increment_total_errors() return row result_rows = run_bulk_process( - handle_row, csv_rows, progress_label="Reactivating devices:" + handle_row, + csv_rows, + progress_label="Reactivating devices:", + stats=stats, + raise_global_error=False, ) formatter.echo_formatted_list(result_rows) diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 4518a9130..2264af46d 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -13,6 +13,7 @@ from code42cli.output_formats import DataFrameOutputFormatter from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter +from code42cli.worker import create_worker_stats @click.group(cls=OrderedGroup) @@ -45,7 +46,6 @@ def users(state): "--org-id", help="The identifier for the organization to which the user will be moved.", required=True, - type=int, ) @@ -156,14 +156,15 @@ def update_user( @org_id_option @sdk_options() def change_organization(state, username, org_id): - """Change the organization of the user with the given username to the org with the given org ID.""" + """Change the organization of the user with the given username + to the org with the given org ID.""" _change_organization(state.sdk, username, org_id) @users.group(cls=OrderedGroup) @sdk_options(hidden=True) def bulk(state): - """Tools for managing users in bulk""" + """Tools for managing users in bulk.""" pass @@ -178,7 +179,11 @@ def bulk(state): bulk.add_command(users_generate_template) -@bulk.command(name="update") +@bulk.command( + name="update", + help="Update a list of users from the provided CSV in format: " + f"{','.join(_bulk_user_update_headers)}", +) @read_csv_arg(headers=_bulk_user_update_headers) @format_option @sdk_options() @@ -186,6 +191,7 @@ def bulk_update(state, csv_rows, format): """Update a list of users from the provided CSV.""" csv_rows[0]["updated"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) def handle_row(**row): try: @@ -195,15 +201,24 @@ def handle_row(**row): row["updated"] = "True" except Exception as err: row["updated"] = f"False: {err}" + stats.increment_total_errors() return row result_rows = run_bulk_process( - handle_row, csv_rows, progress_label="Updating users:" + handle_row, + csv_rows, + progress_label="Updating users:", + stats=stats, + raise_global_error=False, ) formatter.echo_formatted_list(result_rows) -@bulk.command(name="move") +@bulk.command( + name="move", + help="Change the organization of the list of users from the provided CSV in format: " + f"{','.join(_bulk_user_move_headers)}", +) @read_csv_arg(headers=_bulk_user_move_headers) @format_option @sdk_options() @@ -211,6 +226,7 @@ def bulk_move(state, csv_rows, format): """Change the organization of the list of users from the provided CSV.""" csv_rows[0]["moved"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) def handle_row(**row): try: @@ -220,9 +236,16 @@ def handle_row(**row): row["moved"] = "True" except Exception as err: row["moved"] = f"False: {err}" + stats.increment_total_errors() return row - result_rows = run_bulk_process(handle_row, csv_rows, progress_label="Moving users:") + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Moving users:", + stats=stats, + raise_global_error=False, + ) formatter.echo_formatted_list(result_rows) @@ -291,4 +314,10 @@ def _update_user( def _change_organization(sdk, username, org_id): user_id = _get_user_id(sdk, username) + org_id = _get_org_id(sdk, org_id) return sdk.users.change_org_assignment(user_id=int(user_id), org_id=int(org_id)) + + +def _get_org_id(sdk, org_id): + org = sdk.orgs.get_by_uid(org_id) + return org["orgId"] diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index a673dc8ee..8c6004c00 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -10,6 +10,10 @@ from code42cli.logger import get_main_cli_logger +def create_worker_stats(total): + return WorkerStats(total) + + class WorkerStats: """Stats about the tasks that have run.""" @@ -66,15 +70,15 @@ def reset_results(self): class Worker: - def __init__(self, thread_count, expected_total, bar=None): + def __init__(self, thread_count, expected_total, bar=None, stats=None): self._queue = queue.Queue() self._thread_count = thread_count - self._stats = WorkerStats(expected_total) + self._bar = bar + self._stats = stats or WorkerStats(expected_total) self._tasks = 0 self.__started = False self.__start_lock = Lock() self._logger = get_main_cli_logger() - self._bar = bar def do_async(self, func, *args, **kwargs): """Execute the given func asynchronously given *args and **kwargs. diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index edce17ebe..824b50304 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -19,6 +19,7 @@ from code42cli.cmds.devices import _break_backup_usage_into_total_storage from code42cli.cmds.devices import _get_device_dataframe from code42cli.main import cli +from code42cli.worker import WorkerStats _NAMESPACE = "code42cli.cmds.devices" TEST_DATE_OLDER = "2020-01-01T12:00:00.774Z" @@ -463,6 +464,18 @@ def get_all_custodian_success(cli_state): ) +@pytest.fixture +def worker_stats_factory(mocker): + return mocker.patch(f"{_NAMESPACE}.create_worker_stats") + + +@pytest.fixture +def worker_stats(mocker, worker_stats_factory): + stats = mocker.MagicMock(spec=WorkerStats) + worker_stats_factory.return_value = stats + return stats + + def test_deactivate_deactivates_device( runner, cli_state, deactivate_device_success, get_device_by_guid_success ): @@ -829,6 +842,52 @@ def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state): ] +def test_bulk_deactivate_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_deactivate.csv", "w") as csv: + csv.writelines(["guid,username\n", "\n", "test,value\n\n"]) + runner.invoke( + cli, + ["devices", "bulk", "deactivate", "test_bulk_deactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + { + "guid": "test", + "deactivated": "False", + "change_device_name": False, + "purge_date": None, + } + ] + + +def test_bulk_deactivate_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats +): + lines = ["guid\n", "1\n"] + + def _get(guid): + if guid == "test": + raise Exception("TEST") + return _create_py42_response(mocker, TEST_DEVICE_RESPONSE) + + cli_state.sdk.devices.get_by_guid.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_deactivate.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, + ["devices", "bulk", "deactivate", "test_bulk_deactivate.csv"], + obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler(guid="test", change_device_name="test", purge_date="test") + handler(guid="not test", change_device_name="test", purge_date="test") + assert worker_stats.increment_total_errors.call_count == 1 + + def test_bulk_reactivate_uses_expected_arguments(runner, mocker, cli_state): bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): @@ -839,4 +898,44 @@ def test_bulk_reactivate_uses_expected_arguments(runner, mocker, cli_state): ["devices", "bulk", "reactivate", "test_bulk_reactivate.csv"], obj=cli_state, ) - assert bulk_processor.call_args[0][1] == [{"guid": "test", "reactivated": False}] + assert bulk_processor.call_args[0][1] == [{"guid": "test", "reactivated": "False"}] + + +def test_bulk_reactivate_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_reactivate.csv", "w") as csv: + csv.writelines(["guid,username\n", "\n", "test,value\n\n"]) + runner.invoke( + cli, + ["devices", "bulk", "reactivate", "test_bulk_reactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [{"guid": "test", "reactivated": "False"}] + bulk_processor.assert_called_once() + + +def test_bulk_reactivate_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats +): + lines = ["guid\n", "1\n"] + + def _get(guid): + if guid == "test": + raise Exception("TEST") + return _create_py42_response(mocker, TEST_DEVICE_RESPONSE) + + cli_state.sdk.devices.get_by_guid.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_reactivate.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, + ["devices", "bulk", "reactivate", "test_bulk_reactivate.csv"], + obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler(guid="test") + handler(guid="not test") + assert worker_stats.increment_total_errors.call_count == 1 diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 9427dd363..fb539af76 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -5,7 +5,7 @@ from requests import Response from code42cli.main import cli - +from code42cli.worker import WorkerStats _NAMESPACE = "code42cli.cmds.users" @@ -38,6 +38,34 @@ TEST_USERNAME = TEST_USERS_RESPONSE["users"][0]["username"] TEST_USER_ID = TEST_USERS_RESPONSE["users"][0]["userId"] TEST_ROLE_NAME = TEST_ROLE_RETURN_DATA["data"][0]["roleName"] +TEST_GET_ORG_RESPONSE = { + "orgId": 9087, + "orgUid": "1007759454961904673", + "orgGuid": "a9578c3d-736b-4d96-80e5-71edd8a11ea3", + "orgName": "19may", + "orgExtRef": None, + "notes": None, + "status": "Active", + "active": True, + "blocked": False, + "parentOrgId": 2689, + "parentOrgUid": "890854247383106706", + "parentOrgGuid": "8c97h74umc2s8mmm", + "type": "ENTERPRISE", + "classification": "BASIC", + "externalId": "1007759454961904673", + "hierarchyCounts": {}, + "configInheritanceCounts": {}, + "creationDate": "2021-05-19T10:10:43.459Z", + "modificationDate": "2021-05-20T14:43:42.276Z", + "deactivationDate": None, + "settings": {"maxSeats": None, "maxBytes": None}, + "settingsInherited": {"maxSeats": "", "maxBytes": ""}, + "settingsSummary": {"maxSeats": "", "maxBytes": ""}, + "registrationKey": "72RU-8P9S-M7KK-RHCC", + "reporting": {"orgManagers": []}, + "customConfig": False, +} def _create_py42_response(mocker, text): @@ -62,24 +90,42 @@ def get_available_roles_response(mocker): return _create_py42_response(mocker, json.dumps(TEST_ROLE_RETURN_DATA)) +@pytest.fixture +def get_users_response(mocker): + return _create_py42_response(mocker, json.dumps(TEST_USERS_RESPONSE)) + + @pytest.fixture def change_org_response(mocker): return _create_py42_response(mocker, "") +@pytest.fixture +def get_org_response(mocker): + return _create_py42_response(mocker, json.dumps(TEST_GET_ORG_RESPONSE)) + + +@pytest.fixture +def get_org_success(cli_state, get_org_response): + cli_state.sdk.orgs.get_by_uid.return_value = get_org_response + + @pytest.fixture def get_all_users_success(cli_state): cli_state.sdk.users.get_all.return_value = get_all_users_generator() @pytest.fixture -def get_user_id_success(cli_state): - cli_state.sdk.users.get_by_username.return_value = TEST_USERS_RESPONSE +def get_user_id_success(cli_state, get_users_response): + """Get by username returns a list of users""" + cli_state.sdk.users.get_by_username.return_value = get_users_response @pytest.fixture -def get_user_id_failure(cli_state): - cli_state.sdk.users.get_by_username.return_value = TEST_EMPTY_USERS_RESPONSE +def get_user_id_failure(mocker, cli_state): + cli_state.sdk.users.get_by_username.return_value = _create_py42_response( + mocker, json.dumps(TEST_EMPTY_USERS_RESPONSE) + ) @pytest.fixture @@ -97,6 +143,18 @@ def change_org_success(cli_state, change_org_response): cli_state.sdk.users.change_org_assignment.return_value = change_org_response +@pytest.fixture +def worker_stats_factory(mocker): + return mocker.patch(f"{_NAMESPACE}.create_worker_stats") + + +@pytest.fixture +def worker_stats(mocker, worker_stats_factory): + stats = mocker.MagicMock(spec=WorkerStats) + worker_stats_factory.return_value = stats + return stats + + def test_list_when_non_table_format_outputs_expected_columns( runner, cli_state, get_all_users_success ): @@ -340,7 +398,7 @@ def test_update_user_calls_update_user_with_correct_parameters_when_all_are_pass ) -def test_bulk_deactivate_uses_expected_arguments_when_only_some_are_passed( +def test_bulk_update_uses_expected_arguments_when_only_some_are_passed( runner, mocker, cli_state ): bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") @@ -370,7 +428,7 @@ def test_bulk_deactivate_uses_expected_arguments_when_only_some_are_passed( ] -def test_bulk_deactivate_uses_expected_arguments_when_all_are_passed( +def test_bulk_update_uses_expected_arguments_when_all_are_passed( runner, mocker, cli_state ): bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") @@ -400,13 +458,96 @@ def test_bulk_deactivate_uses_expected_arguments_when_all_are_passed( ] -def test_change_org_calls_change_org_assignment_with_correct_parameters( - runner, cli_state, change_org_success, get_user_id_success +def test_bulk_update_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_update.csv", "w") as csv: + csv.writelines( + [ + "user_id,username,email,password,first_name,last_name,notes,archive_size_quota\n", + "\n", + "12345,test_username,test_email,test_pword,test_fname,test_lname,test notes,4321\n", + "\n", + ] + ) + runner.invoke( + cli, ["users", "bulk", "update", "test_bulk_update.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [ + { + "user_id": "12345", + "username": "test_username", + "email": "test_email", + "password": "test_pword", + "first_name": "test_fname", + "last_name": "test_lname", + "notes": "test notes", + "archive_size_quota": "4321", + "updated": "False", + } + ] + + +def test_bulk_update_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats ): - command = ["users", "move", "--username", TEST_USERNAME, "--org-id", "54321"] + lines = [ + "user_id,username,email,password,first_name,last_name,notes,archive_size_quota\n", + "12345,test_username,test_email,test_pword,test_fname,test_lname,test notes,4321\n", + ] + + def _update(user_id, *args, **kwargs): + if user_id == "12345": + raise Exception("TEST") + return _create_py42_response(mocker, TEST_USERS_RESPONSE) + + cli_state.sdk.users.update_user.side_effect = _update + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_update.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, ["users", "bulk", "update", "test_bulk_update.csv"], obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler( + user_id="12345", + username="test", + email="test", + password="test", + first_name="test", + last_name="test", + notes="test", + archive_size_quota="test", + ) + handler( + user_id="not 12345", + username="test", + email="test", + password="test", + first_name="test", + last_name="test", + notes="test", + archive_size_quota="test", + ) + assert worker_stats.increment_total_errors.call_count == 1 + + +def test_move_calls_change_org_assignment_with_correct_parameters( + runner, cli_state, change_org_success, get_user_id_success, get_org_success +): + command = [ + "users", + "move", + "--username", + TEST_USERNAME, + "--org-id", + "1007744453331222111", + ] runner.invoke(cli, command, obj=cli_state) + expected_org_id = TEST_GET_ORG_RESPONSE["orgId"] cli_state.sdk.users.change_org_assignment.assert_called_once_with( - user_id=TEST_USER_ID, org_id=54321 + user_id=TEST_USER_ID, org_id=expected_org_id ) @@ -422,3 +563,41 @@ def test_bulk_move_uses_expected_arguments(runner, mocker, cli_state): {"username": TEST_USERNAME, "org_id": "4321", "moved": "False"} ] bulk_processor.assert_called_once() + + +def test_bulk_move_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_move.csv", "w") as csv: + csv.writelines(["username,org_id\n\n\n", f"{TEST_USERNAME},4321\n\n\n"]) + runner.invoke( + cli, ["users", "bulk", "move", "test_bulk_move.csv"], obj=cli_state + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "org_id": "4321", "moved": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_move_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats, get_users_response +): + lines = ["username,org_id\n", f"{TEST_USERNAME},4321\n"] + + def _get(username, *args, **kwargs): + if username == "test@example.com": + raise Exception("TEST") + return get_users_response + + cli_state.sdk.users.get_by_username.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_move.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, ["users", "bulk", "move", "test_bulk_move.csv"], obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler(username="test@example.com", org_id="test") + handler(username="not.test@example.com", org_id="test") + assert worker_stats.increment_total_errors.call_count == 1 diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 168682f53..51730995c 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -77,7 +77,9 @@ def test_run_bulk_process_creates_processor(bulk_processor_factory): errors.ERRORED = False rows = [1, 2] run_bulk_process(func_with_one_arg, rows) - bulk_processor_factory.assert_called_once_with(func_with_one_arg, rows, None) + bulk_processor_factory.assert_called_once_with( + func_with_one_arg, rows, None, stats=None, raise_global_error=True + ) class TestBulkProcessor: From 281002d36e994cc08c5d7360e936d4238d50a327 Mon Sep 17 00:00:00 2001 From: docjake Date: Thu, 1 Jul 2021 17:08:35 -0500 Subject: [PATCH 257/349] Update siemexample.md (#304) Fixed typo in the Attribute mapping table: changed modifyTimesamp to modifyTimestamp Co-authored-by: Juliya Smith --- docs/userguides/siemexample.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index d8e8f773e..d65575f23 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -171,7 +171,7 @@ to one another. +----------------------------+---------------------------------+----------------------------------------+ | md5Checksum | fileHash | MD5 Hash | +----------------------------+---------------------------------+----------------------------------------+ -| modifyTimesamp | fileModificationTime | File Modified Date | +| modifyTimestamp | fileModificationTime | File Modified Date | +----------------------------+---------------------------------+----------------------------------------+ | osHostName | shost | Hostname | +----------------------------+---------------------------------+----------------------------------------+ From 57bfdb25564a781aecfe85e4db17805cfa76ce3e Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 2 Jul 2021 13:42:51 -0500 Subject: [PATCH 258/349] BugFixes: Allow checkpointing to work when API returns timestamps with nanoseconds (#305) --- CHANGELOG.md | 9 + src/code42cli/cmds/auditlogs.py | 45 ++-- src/code42cli/cmds/devices.py | 8 + src/code42cli/cmds/users.py | 17 +- src/code42cli/errors.py | 4 +- src/code42cli/output_formats.py | 28 ++- src/code42cli/util.py | 27 ++- tests/cmds/test_auditlogs.py | 317 ++++++++++++++++++++++---- tests/cmds/test_departing_employee.py | 10 +- tests/cmds/test_high_risk_employee.py | 10 +- tests/cmds/test_users.py | 29 ++- tests/conftest.py | 11 + tests/test_errors.py | 0 tests/test_output_formats.py | 8 +- 14 files changed, 422 insertions(+), 101 deletions(-) delete mode 100644 tests/test_errors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df168e7b0..94c732a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Issue where `code42 devices bulk deactivate` and `code42 devices bulk reactivate` would output incorrect Successes and Failures at the end of the process. +- Bug where `code42 audit-logs search` would fail to store checkpoints when timestamps included + nanoseconds. + +- Issue where if an error occurred during `code42 audit-logs search` or `code42 audit-logs send-to`, + the user would get a stored checkpoint without having handled events. + ### Added - New command `code42 users update` to update a single user. @@ -29,6 +35,9 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 alerts search` now uses microsecond precision when searching by alerts' date observed. +- Now when a user is not found, the error message suggests that it might be because you don't + have the necessary permissions. + ## 1.7.0 - 2021-06-17 ### Added diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index f68d97236..ba0c2e6f0 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -1,6 +1,3 @@ -from datetime import datetime -from datetime import timezone - import click import code42cli.options as opt @@ -14,11 +11,11 @@ from code42cli.options import sdk_options from code42cli.output_formats import OutputFormatter from code42cli.util import hash_event +from code42cli.util import parse_timestamp from code42cli.util import warn_interrupt EVENT_KEY = "events" AUDIT_LOGS_KEYWORD = "audit-logs" -AUDIT_LOG_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" def _get_audit_logs_default_header(): @@ -145,17 +142,18 @@ def search( affected_user_ids=affected_user_id, affected_usernames=affected_username, ) - if use_checkpoint: - checkpoint_name = use_checkpoint - events = list( - _dedupe_checkpointed_events_and_store_updated_checkpoint( - cursor, checkpoint_name, events - ) - ) if not events: click.echo("No results found.") return - formatter.echo_formatted_list(events) + + if use_checkpoint: + checkpoint_name = use_checkpoint + events_gen = _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, events + ) + formatter.echo_formatted_generated_output(events_gen) + else: + formatter.echo_formatted_list(events) @audit_logs.command(cls=SendToCommand) @@ -200,10 +198,8 @@ def send_to( ) if use_checkpoint: checkpoint_name = use_checkpoint - events = list( - _dedupe_checkpointed_events_and_store_updated_checkpoint( - cursor, checkpoint_name, events - ) + events = _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, events ) with warn_interrupt(): event = None @@ -254,25 +250,10 @@ def _dedupe_checkpointed_events_and_store_updated_checkpoint( new_events.clear() new_events.append(event_hash) yield event - ts = _parse_audit_log_timestamp_string_to_timestamp(new_timestamp) + ts = parse_timestamp(new_timestamp) cursor.replace(checkpoint_name, ts) cursor.replace_events(checkpoint_name, new_events) def _get_audit_log_cursor_store(profile_name): return AuditLogCursorStore(profile_name) - - -def _parse_audit_log_timestamp_string_to_timestamp(ts): - # example: {"property": "bar", "timestamp": "2020-11-23T17:13:26.239647Z"} - ts = ts[:-1] - try: - dt = datetime.strptime(ts, AUDIT_LOG_TIMESTAMP_FORMAT).replace( - tzinfo=timezone.utc - ) - except ValueError: - ts = ts + ".0" # handle timestamps that are missing ms - dt = datetime.strptime(ts, AUDIT_LOG_TIMESTAMP_FORMAT).replace( - tzinfo=timezone.utc - ) - return dt.timestamp() diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 6de1649e7..db766fa2b 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -573,7 +573,11 @@ def bulk(state): @sdk_options() def bulk_deactivate(state, csv_rows, change_device_name, purge_date, format): """Deactivate all devices from the provided CSV containing a 'guid' column.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. sdk = state.sdk + csv_rows[0]["deactivated"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) stats = create_worker_stats(len(csv_rows)) @@ -608,7 +612,11 @@ def handle_row(**row): @sdk_options() def bulk_reactivate(state, csv_rows, format): """Reactivate all devices from the provided CSV containing a 'guid' column.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. sdk = state.sdk + csv_rows[0]["reactivated"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) stats = create_worker_stats(len(csv_rows)) diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 2264af46d..a8a5666ab 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -189,6 +189,11 @@ def bulk(state): @sdk_options() def bulk_update(state, csv_rows, format): """Update a list of users from the provided CSV.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + csv_rows[0]["updated"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) stats = create_worker_stats(len(csv_rows)) @@ -196,7 +201,7 @@ def bulk_update(state, csv_rows, format): def handle_row(**row): try: _update_user( - state.sdk, **{key: row[key] for key in row.keys() if key != "updated"} + sdk, **{key: row[key] for key in row.keys() if key != "updated"} ) row["updated"] = "True" except Exception as err: @@ -224,6 +229,11 @@ def handle_row(**row): @sdk_options() def bulk_move(state, csv_rows, format): """Change the organization of the list of users from the provided CSV.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + csv_rows[0]["moved"] = "False" formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) stats = create_worker_stats(len(csv_rows)) @@ -231,7 +241,7 @@ def bulk_move(state, csv_rows, format): def handle_row(**row): try: _change_organization( - state.sdk, **{key: row[key] for key in row.keys() if key != "moved"} + sdk, **{key: row[key] for key in row.keys() if key != "moved"} ) row["moved"] = "True" except Exception as err: @@ -262,6 +272,9 @@ def _remove_user_role(sdk, role_name, username): def _get_user_id(sdk, username): + if not username: + # py42 returns all users when passing `None` to `get_by_username()`. + raise click.BadParameter("Username is required.") user = sdk.users.get_by_username(username)["users"] if len(user) == 0: raise UserDoesNotExistError(username) diff --git a/src/code42cli/errors.py b/src/code42cli/errors.py index 416859201..b66b588c2 100644 --- a/src/code42cli/errors.py +++ b/src/code42cli/errors.py @@ -51,7 +51,9 @@ class UserDoesNotExistError(Code42CLIError): bulk add or remove.""" def __init__(self, username): - super().__init__(f"User '{username}' does not exist.") + super().__init__( + f"User '{username}' does not exist or you do not have permission to view them." + ) class UserNotInLegalHoldError(Code42CLIError): diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 6a24b007e..e7135f1ea 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -12,6 +12,8 @@ CEF_DEFAULT_PRODUCT_NAME = "Advanced Exfiltration Detection" CEF_DEFAULT_SEVERITY_LEVEL = "5" + +# Uses method `output_via_pager()` when 10 or more records. OUTPUT_VIA_PAGER_THRESHOLD = 10 @@ -54,11 +56,11 @@ def __init__(self, output_format, header=None): elif output_format == OutputFormat.JSON: self._format_func = to_formatted_json - def _format_output(self, output): - return self._format_func(output) + def _format_output(self, output, *args, **kwargs): + return self._format_func(output, *args, **kwargs) - def _to_table(self, output): - return to_table(output, self.header) + def _to_table(self, output, include_header=True): + return to_table(output, self.header, include_header=include_header) def get_formatted_output(self, output): if self._requires_list_output: @@ -77,6 +79,19 @@ def echo_formatted_list(self, output_list, force_pager=False): if self.output_format in [OutputFormat.TABLE]: click.echo() + def echo_formatted_generated_output(self, output_generator): + def _gen(): + include_header = True + for output in output_generator: + if output: + formatted_output = self._format_output( + output, include_header=include_header + ) + yield formatted_output + include_header = False + + click.echo_via_pager(_gen) + @property def _requires_list_output(self): return self.output_format in (OutputFormat.TABLE, OutputFormat.CSV) @@ -147,11 +162,12 @@ def to_csv(output): return string_io.getvalue() -def to_table(output, header): +def to_table(output, header, include_header=True): """Output is a list of records""" if not output: return - rows, column_size = find_format_width(output, header) + + rows, column_size = find_format_width(output, header, include_header=include_header) return format_to_table(rows, column_size) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index 3ac5d1678..cdadccaff 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -1,7 +1,7 @@ import json import os import shutil -from collections import OrderedDict +from datetime import timezone from functools import wraps from hashlib import md5 from os import path @@ -9,6 +9,7 @@ from signal import SIGINT from signal import signal +import dateutil.parser from click import echo from click import get_current_context from click import style @@ -40,28 +41,31 @@ def get_user_project_path(*subdirs): return result_path -def find_format_width(record, header, include_header=True): +def find_format_width(records, header, include_header=True): """Fetches needed keys/items to be displayed based on header keys. Finds the largest string against each column so as to decide the padding size for the column. Args: - record (dict): data to be formatted. - header (dict): key-value where keys should map to keys of record dict and + records (list or dict): A list of data to be formatted. + header (dict): Key-value where keys should map to keys of record dict and value is the corresponding column name to be displayed on the CLI. - include_header (bool): include header in output, defaults to True. + include_header (bool): Include header in output, defaults to True. Returns: tuple (list of dict, dict): i.e Filtered records, padding size of columns. """ + if isinstance(records, dict): + records = [records] + rows = [] if include_header: if not header: - header = _get_default_header(record) + header = _get_default_header(records) rows.append(header) widths = dict(header.items()) # Copy - for record_row in record: - row = OrderedDict() + for record_row in records: + row = {} for header_key in header.keys(): item = record_row.get(header_key) row[header_key] = item @@ -173,3 +177,10 @@ def hash_event(event): if isinstance(event, dict): event = json.dumps(event, sort_keys=True) return md5(event.encode()).hexdigest() + + +def parse_timestamp(date_str): + # example: {"property": "bar", "timestamp": "2020-11-23T17:13:26.239647Z"} + ts = date_str[:-1] + date = dateutil.parser.parse(ts).replace(tzinfo=timezone.utc) + return date.timestamp() diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index 7b0e3cca3..032209f11 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -4,12 +4,10 @@ from logging import Logger import pytest -from py42.response import Py42Response -from requests import Response from tests.cmds.conftest import get_mark_for_search_and_send_to +from tests.conftest import create_mock_response from code42cli.click_ext.types import MagicDate -from code42cli.cmds.auditlogs import _parse_audit_log_timestamp_string_to_timestamp from code42cli.cmds.search.cursor_store import AuditLogCursorStore from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import round_datetime_to_day_end @@ -17,13 +15,12 @@ from code42cli.logger.handlers import ServerProtocol from code42cli.main import cli from code42cli.util import hash_event +from code42cli.util import parse_timestamp TEST_AUDIT_LOG_TIMESTAMP_1 = "2020-01-01T12:00:00.000Z" TEST_AUDIT_LOG_TIMESTAMP_2 = "2020-02-01T12:01:00.000111Z" TEST_AUDIT_LOG_TIMESTAMP_3 = "2020-03-01T02:00:00.123456Z" -CURSOR_TIMESTAMP = _parse_audit_log_timestamp_string_to_timestamp( - TEST_AUDIT_LOG_TIMESTAMP_3 -) +CURSOR_TIMESTAMP = parse_timestamp(TEST_AUDIT_LOG_TIMESTAMP_3) TEST_EVENTS_WITH_SAME_TIMESTAMP = [ { "type$": "audit_log::logged_in/1", @@ -106,34 +103,95 @@ def send_to_logger(mocker, send_to_logger_factory): @pytest.fixture -def test_audit_log_response(mocker): - http_response1 = mocker.MagicMock(spec=Response) - http_response1.status_code = 200 - http_response1.text = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) - http_response1._content_consumed = "" - - http_response2 = mocker.MagicMock(spec=Response) - http_response2.status_code = 200 - http_response2.text = json.dumps({"events": TEST_EVENTS_WITH_DIFFERENT_TIMESTAMPS}) - http_response2._content_consumed = "" - Py42Response(http_response2) +def mock_audit_log_response(mocker): + response1 = create_mock_response( + mocker, json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) + ) + response2 = create_mock_response( + mocker, json.dumps({"events": TEST_EVENTS_WITH_DIFFERENT_TIMESTAMPS}) + ) def response_gen(): - yield Py42Response(http_response1) - yield Py42Response(http_response2) + yield response1 + yield response2 return response_gen() @pytest.fixture -def test_audit_log_response_with_only_same_timestamps(mocker): - http_response = mocker.MagicMock(spec=Response) - http_response.status_code = 200 - http_response.text = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) - http_response._content_consumed = "" +def mock_audit_log_response_with_10_records(mocker): + text = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) + responses = [] + for _ in range(0, 10): + responses.append(create_mock_response(mocker, text)) def response_gen(): - yield Py42Response(http_response) + yield from responses + + return response_gen() + + +@pytest.fixture +def mock_audit_log_response_with_only_same_timestamps(mocker): + text = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) + + def response_gen(): + yield create_mock_response(mocker, text) + + return response_gen() + + +@pytest.fixture +def mock_audit_log_response_with_missing_ms_timestamp(mocker): + event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) + event["timestamp"] = "2020-01-01T12:00:00Z" + response_data = {"events": [event]} + text = json.dumps(response_data) + + def response_gen(): + yield create_mock_response(mocker, text) + + return response_gen() + + +@pytest.fixture +def mock_audit_log_response_with_micro_seconds(mocker): + event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) + event["timestamp"] = "2021-07-01T14:47:13.093616Z" + response_data = {"events": [event]} + text = json.dumps(response_data) + + def response_gen(): + yield create_mock_response(mocker, text) + + return response_gen() + + +@pytest.fixture +def mock_audit_log_response_with_nano_seconds(mocker): + event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) + event["timestamp"] = "2021-07-01T14:47:13.093616500Z" + response_data = {"events": [event]} + text = json.dumps(response_data) + + def response_gen(): + yield create_mock_response(mocker, text) + + return response_gen() + + +@pytest.fixture +def mock_audit_log_response_with_error_causing_timestamp(mocker): + good_event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) + bad_event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) + bad_event["timestamp"] = "I AM NOT A TIMESTAMP" # Will cause a ValueError. + response_data = { + "events": [good_event, bad_event] + } # good_event should still get processed. + text = json.dumps(response_data) + + def response_gen(): + yield create_mock_response(mocker, text) return response_gen() @@ -231,9 +289,9 @@ def test_search_and_send_to_handles_all_filter_parameters( def test_send_to_makes_expected_call_count_to_the_logger_method( - cli_state, runner, send_to_logger, test_audit_log_response + cli_state, runner, send_to_logger, mock_audit_log_response ): - cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + cli_state.sdk.auditlogs.get_all.return_value = mock_audit_log_response runner.invoke( cli, ["audit-logs", "send-to", "localhost", "--begin", "1d"], obj=cli_state ) @@ -284,9 +342,9 @@ def test_send_to_when_given_ignore_cert_validation_uses_certs_equal_to_ignore_st def test_send_to_emits_events_in_chronological_order( - cli_state, runner, send_to_logger, test_audit_log_response + cli_state, runner, send_to_logger, mock_audit_log_response ): - cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + cli_state.sdk.auditlogs.get_all.return_value = mock_audit_log_response runner.invoke( cli, ["audit-logs", "send-to", "localhost", "--begin", "1d"], obj=cli_state ) @@ -359,11 +417,11 @@ def test_search_and_send_to_with_checkpoint_saves_expected_cursor_timestamp( cli_state, runner, send_to_logger, - test_audit_log_response, + mock_audit_log_response, audit_log_cursor_with_checkpoint, command, ): - cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + cli_state.sdk.auditlogs.get_all.return_value = mock_audit_log_response runner.invoke( cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, ) @@ -379,7 +437,7 @@ def test_search_and_send_to_with_existing_checkpoint_replaces_begin_arg_if_passe cli_state, runner, send_to_logger, - test_audit_log_response, + mock_audit_log_response, audit_log_cursor_with_checkpoint, command, ): @@ -394,10 +452,10 @@ def test_search_and_send_to_with_existing_checkpoint_replaces_begin_arg_if_passe def test_search_with_existing_checkpoint_events_skips_duplicate_events( cli_state, runner, - test_audit_log_response, + mock_audit_log_response, audit_log_cursor_with_checkpoint_and_events, ): - cli_state.sdk.auditlogs.get_all.return_value = test_audit_log_response + cli_state.sdk.auditlogs.get_all.return_value = mock_audit_log_response result = runner.invoke( cli, ["audit-logs", "search", "--begin", "1d", "--use-checkpoint", "test"], @@ -412,12 +470,12 @@ def test_search_and_send_to_without_existing_checkpoint_writes_both_event_hashes cli_state, runner, send_to_logger, - test_audit_log_response_with_only_same_timestamps, + mock_audit_log_response_with_only_same_timestamps, audit_log_cursor_with_checkpoint, command, ): cli_state.sdk.auditlogs.get_all.return_value = ( - test_audit_log_response_with_only_same_timestamps + mock_audit_log_response_with_only_same_timestamps ) runner.invoke( cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, @@ -472,9 +530,184 @@ def test_send_to_certs_and_ignore_cert_validation_args_are_incompatible( assert "Error: --ignore-cert-validation can't be used with: --certs" in res.output -def test_audit_log_parse_timestamp_handles_possible_strings(): - TIMESTAMP_WITH_MILLISECONDS = "2020-01-01T12:00:00.000Z" - TIMESTAMP_WITHOUT_MILLISECONDS = "2020-01-01T12:00:00Z" - ts1 = _parse_audit_log_timestamp_string_to_timestamp(TIMESTAMP_WITH_MILLISECONDS) - ts2 = _parse_audit_log_timestamp_string_to_timestamp(TIMESTAMP_WITHOUT_MILLISECONDS) - assert ts1 == ts2 +@search_and_send_to_test +def test_search_and_send_when_timestamps_missing_milliseconds_saves_checkpoint( + cli_state, + runner, + send_to_logger, + mock_audit_log_response_with_missing_ms_timestamp, + audit_log_cursor_with_checkpoint, + command, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + mock_audit_log_response_with_missing_ms_timestamp + ) + runner.invoke( + cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + ) + audit_log_cursor_with_checkpoint.replace.assert_called_once_with( + "test", 1577880000.0 + ) + + +@search_and_send_to_test +def test_search_and_send_when_timestamps_have_microseconds_saves_checkpoint( + cli_state, + runner, + send_to_logger, + mock_audit_log_response_with_micro_seconds, + audit_log_cursor_with_checkpoint, + command, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + mock_audit_log_response_with_micro_seconds + ) + runner.invoke( + cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + ) + audit_log_cursor_with_checkpoint.replace.assert_called_once_with( + "test", 1625150833.093616 + ) + + +@search_and_send_to_test +def test_search_and_send_when_timestamps_have_nanoseconds_saves_checkpoint( + cli_state, + runner, + send_to_logger, + mock_audit_log_response_with_nano_seconds, + audit_log_cursor_with_checkpoint, + command, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + mock_audit_log_response_with_nano_seconds + ) + runner.invoke( + cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + ) + call_args = audit_log_cursor_with_checkpoint.replace.call_args + assert call_args[0][0] == "test" + assert call_args[0][1] == 1625150833.093616 + + +def test_search_if_error_occurs_when_processing_event_timestamp_still_outputs_results( + cli_state, + runner, + mock_audit_log_response_with_error_causing_timestamp, + audit_log_cursor_with_checkpoint, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + mock_audit_log_response_with_error_causing_timestamp + ) + res = runner.invoke( + cli, ["audit-logs", "search", "--use-checkpoint", "test"], obj=cli_state, + ) + assert TEST_AUDIT_LOG_TIMESTAMP_1 in res.output + assert "I AM NOT A TIMESTAMP" in res.output + assert "Error: Unknown problem occurred." in res.output + + +def test_search_if_error_occurs_when_processing_event_timestamp_does_not_store_error_timestamp( + cli_state, + runner, + mock_audit_log_response_with_error_causing_timestamp, + audit_log_cursor_with_checkpoint, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + mock_audit_log_response_with_error_causing_timestamp + ) + runner.invoke( + cli, ["audit-logs", "search", "--use-checkpoint", "test"], obj=cli_state, + ) + + # Saved the timestamp from the good event but not the bad event + audit_log_cursor_with_checkpoint.replace.assert_called_once_with( + "test", 1577880000.0 + ) + + +def test_search_when_table_format_and_using_output_via_pager_only_includes_header_keys_once( + cli_state, + runner, + mock_audit_log_response_with_10_records, + audit_log_cursor_with_checkpoint, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + mock_audit_log_response_with_10_records + ) + result = runner.invoke( + cli, ["audit-logs", "search", "--use-checkpoint", "test"], obj=cli_state, + ) + output = result.output + output = output.split(" ") + output = [s for s in output if s] + assert ( + output.count("Timestamp") + == output.count("ActorName") + == output.count("ActorIpAddress") + == output.count("AffectedUserUID") + == 1 + ) + + +def test_send_to_if_error_occurs_still_processes_events( + cli_state, + runner, + mock_audit_log_response_with_error_causing_timestamp, + audit_log_cursor_with_checkpoint, + send_to_logger, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + mock_audit_log_response_with_error_causing_timestamp + ) + runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--use-checkpoint", + "test", + ], + obj=cli_state, + ) + assert ( + send_to_logger.info.call_args_list[0][0][0]["timestamp"] + == TEST_AUDIT_LOG_TIMESTAMP_1 + ) + assert ( + send_to_logger.info.call_args_list[1][0][0]["timestamp"] + == "I AM NOT A TIMESTAMP" + ) + + +def test_send_to_if_error_occurs_when_processing_event_timestamp_does_not_store_error_timestamp( + cli_state, + runner, + mock_audit_log_response_with_error_causing_timestamp, + audit_log_cursor_with_checkpoint, + send_to_logger, +): + cli_state.sdk.auditlogs.get_all.return_value = ( + mock_audit_log_response_with_error_causing_timestamp + ) + runner.invoke( + cli, + [ + "audit-logs", + "send-to", + "0.0.0.0", + "--begin", + "1d", + "--use-checkpoint", + "test", + ], + obj=cli_state, + ) + + # Saved the timestamp from the good event but not the bad event + audit_log_cursor_with_checkpoint.replace.assert_called_once_with( + "test", 1577880000.0 + ) diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 493b77ea6..d0323bff4 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -188,7 +188,10 @@ def test_add_departing_employee_when_user_does_not_exist_exits( cli, ["departing-employee", "add", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert f"User '{TEST_EMPLOYEE}' does not exist." in result.output + assert ( + f"User '{TEST_EMPLOYEE}' does not exist or you do not have permission to view them." + in result.output + ) def test_add_departing_employee_when_user_already_exits_with_correct_message( @@ -221,7 +224,10 @@ def test_remove_departing_employee_when_user_does_not_exist_exits( cli, ["departing-employee", "remove", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert f"User '{TEST_EMPLOYEE}' does not exist." in result.output + assert ( + f"User '{TEST_EMPLOYEE}' does not exist or you do not have permission to view them." + in result.output + ) def test_add_bulk_users_calls_expected_py42_methods(runner, cli_state): diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index a2a8dab0e..8b8bad199 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -200,7 +200,10 @@ def test_add_high_risk_employee_when_user_does_not_exist_exits_with_correct_mess cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert f"User '{TEST_EMPLOYEE}' does not exist." in result.output + assert ( + f"User '{TEST_EMPLOYEE}' does not exist or you do not have permission to view them." + in result.output + ) def test_add_high_risk_employee_when_user_already_added_exits_with_correct_message( @@ -234,7 +237,10 @@ def test_remove_high_risk_employee_when_user_does_not_exist_exits_with_correct_m cli, ["high-risk-employee", "remove", TEST_EMPLOYEE], obj=cli_state_without_user ) assert result.exit_code == 1 - assert f"User '{TEST_EMPLOYEE}' does not exist." in result.output + assert ( + f"User '{TEST_EMPLOYEE}' does not exist or you do not have permission to view them." + in result.output + ) def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state): diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index fb539af76..393ecdaca 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -293,7 +293,10 @@ def test_add_user_role_raises_error_when_username_does_not_exist( ] result = runner.invoke(cli, command, obj=cli_state) assert result.exit_code == 1 - assert "User 'not_a_username@example.com' does not exist." in result.output + assert ( + "User 'not_a_username@example.com' does not exist or you do not have permission to view them." + in result.output + ) def test_remove_user_role_removes( @@ -342,7 +345,10 @@ def test_remove_user_role_raises_error_when_username_does_not_exist( ] result = runner.invoke(cli, command, obj=cli_state) assert result.exit_code == 1 - assert "User 'not_a_username@example.com' does not exist." in result.output + assert ( + "User 'not_a_username@example.com' does not exist or you do not have permission to view them." + in result.output + ) def test_update_user_calls_update_user_with_correct_parameters_when_only_some_are_passed( @@ -601,3 +607,22 @@ def _get(username, *args, **kwargs): handler(username="test@example.com", org_id="test") handler(username="not.test@example.com", org_id="test") assert worker_stats.increment_total_errors.call_count == 1 + + +def test_bulk_move_uses_handle_than_when_called_and_row_has_missing_username_errors_at_row( + runner, mocker, cli_state, worker_stats +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + lines = ["username,org_id\n", ",123\n"] # Missing username + with runner.isolated_filesystem(): + with open("test_bulk_move.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, ["users", "bulk", "move", "test_bulk_move.csv"], obj=cli_state + ) + + handler = bulk_processor.call_args[0][0] + handler(username=None, org_id="123") + assert worker_stats.increment_total_errors.call_count == 1 + # Ensure it does not try to get the username for the None user. + assert not cli_state.sdk.users.get_by_username.call_count diff --git a/tests/conftest.py b/tests/conftest.py index 66fc9520b..c179261d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,9 @@ import pytest from click.testing import CliRunner +from py42.response import Py42Response from py42.sdk import SDKClient +from requests import Response import code42cli.errors as error_tracker from code42cli.config import ConfigAccessor @@ -281,3 +283,12 @@ def mock_dataframe_to_csv(mocker): @pytest.fixture def mock_dataframe_to_string(mocker): return mocker.patch("pandas.DataFrame.to_string") + + +def create_mock_response(mocker, text): + response = mocker.MagicMock(spec=Response) + response.text = text + response.status_code = 200 + response.encoding = None + response._content_consumed = "" + return Py42Response(response) diff --git a/tests/test_errors.py b/tests/test_errors.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index 9bd723304..bbd2ed124 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -297,7 +297,7 @@ def test_init_sets_format_func_to_table_function_when_table_format_option_is_pas formatter = output_formats_module.OutputFormatter(output_format) for _ in formatter.get_formatted_output("TEST"): pass - mock_to_table.assert_called_once_with("TEST", None) + mock_to_table.assert_called_once_with("TEST", None, include_header=True) def test_init_sets_format_func_to_csv_function_when_csv_format_option_is_passed( self, mock_to_csv @@ -314,7 +314,7 @@ def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed formatter = output_formats_module.OutputFormatter(None) for _ in formatter.get_formatted_output("TEST"): pass - mock_to_table.assert_called_once_with("TEST", None) + mock_to_table.assert_called_once_with("TEST", None, include_header=True) class TestFileEventsOutputFormatter: @@ -356,7 +356,7 @@ def test_init_sets_format_func_to_table_function_when_table_format_option_is_pas formatter = FileEventsOutputFormatter(FileEventsOutputFormat.TABLE) for _ in formatter.get_formatted_output("TEST"): pass - mock_to_table.assert_called_once_with("TEST", None) + mock_to_table.assert_called_once_with("TEST", None, include_header=True) def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed( self, mock_to_table @@ -364,7 +364,7 @@ def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed formatter = FileEventsOutputFormatter(None) for _ in formatter.get_formatted_output("TEST"): pass - mock_to_table.assert_called_once_with("TEST", None) + mock_to_table.assert_called_once_with("TEST", None, include_header=True) def test_to_cef_returns_cef_tagged_string(mock_file_event): From 24a5d1859dbdebc178ce18e9b89ba039e4bd6f19 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 8 Jul 2021 11:42:27 -0500 Subject: [PATCH 259/349] Chore/Incorporate-update-user-custom-errors (#306) --- setup.py | 2 +- src/code42cli/click_ext/groups.py | 6 +++ tests/cmds/conftest.py | 22 ++++---- tests/cmds/detectionlists/test_init.py | 8 +-- tests/cmds/search/test_extraction.py | 14 ++--- tests/cmds/test_alert_rules.py | 22 ++------ tests/cmds/test_alerts.py | 27 +++++----- tests/cmds/test_auditlogs.py | 32 +++++------ tests/cmds/test_devices.py | 23 +++----- tests/cmds/test_users.py | 75 ++++++++++++++++++-------- tests/conftest.py | 18 +++++-- 11 files changed, 128 insertions(+), 121 deletions(-) diff --git a/setup.py b/setup.py index d57c4a1fe..777ffd69e 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython>=7.16.1", "pandas>=1.1.3", - "py42>=1.15.1", + "py42>=1.16.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index 38d3b6b48..f8eba17e6 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -8,7 +8,10 @@ from py42.exceptions import Py42DescriptionLimitExceededError from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42HTTPError +from py42.exceptions import Py42InvalidEmailError +from py42.exceptions import Py42InvalidPasswordError from py42.exceptions import Py42InvalidRuleOperationError +from py42.exceptions import Py42InvalidUsernameError from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError from py42.exceptions import Py42UpdateClosedCaseError from py42.exceptions import Py42UserAlreadyAddedError @@ -69,6 +72,9 @@ def invoke(self, ctx): Py42CaseAlreadyHasEventError, Py42UpdateClosedCaseError, Py42UsernameMustBeEmailError, + Py42InvalidEmailError, + Py42InvalidPasswordError, + Py42InvalidUsernameError, ) as err: self.logger.log_error(err) raise Code42CLIError(str(err)) diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index deeb49deb..78547b85b 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -1,15 +1,16 @@ +import json import json as json_module import threading import pytest from py42.exceptions import Py42UserAlreadyAddedError from py42.exceptions import Py42UserNotOnListError -from py42.response import Py42Response from py42.sdk import SDKClient from requests import HTTPError -from requests import Request from requests import Response from tests.conftest import convert_str_to_date +from tests.conftest import create_mock_http_error +from tests.conftest import create_mock_response from tests.conftest import TEST_ID from code42cli.logger import CliLogger @@ -20,12 +21,8 @@ def get_user_not_on_list_side_effect(mocker, list_name): def side_effect(*args, **kwargs): - err = mocker.MagicMock(spec=HTTPError) - resp = mocker.MagicMock(spec=Response) - resp.text = "TEST_ERR" - err.response = resp - err.response.request = mocker.MagicMock(spec=Request) - raise Py42UserNotOnListError(err, TEST_ID, list_name) + mock_http_error = create_mock_http_error(mocker, data="TEST_ERR") + raise Py42UserNotOnListError(mock_http_error, TEST_ID, list_name) return side_effect @@ -114,12 +111,13 @@ def f(*args): def get_generator_for_get_all(mocker, mock_return_items): - mock_return_items = mock_return_items or "" + if not mock_return_items: + mock_return_items = [] + elif not isinstance(mock_return_items, dict): + mock_return_items = [json.loads(mock_return_items)] def gen(*args, **kwargs): - response = mocker.MagicMock(spec=Request) - response.text = f'{{"items": [{mock_return_items}]}}' - yield Py42Response(response) + yield create_mock_response(mocker, data={"items": mock_return_items}) return gen diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py index b0e853b41..c07f3c404 100644 --- a/tests/cmds/detectionlists/test_init.py +++ b/tests/cmds/detectionlists/test_init.py @@ -1,10 +1,8 @@ import pytest -from py42.response import Py42Response -from requests import Response +from tests.conftest import create_mock_response from code42cli.cmds.detectionlists import update_user - MOCK_USER_ID = "USER-ID" MOCK_USER_NAME = "test@example.com" MOCK_ALIAS = "alias@example" @@ -24,9 +22,7 @@ @pytest.fixture def user_response_with_cloud_aliases(mocker): - response = mocker.MagicMock(spec=Response) - response.text = MOCK_USER_PROFILE_RESPONSE - return Py42Response(response) + return create_mock_response(mocker, data=MOCK_USER_PROFILE_RESPONSE) @pytest.fixture diff --git a/tests/cmds/search/test_extraction.py b/tests/cmds/search/test_extraction.py index 13eeb0f7f..9ca0b04d0 100644 --- a/tests/cmds/search/test_extraction.py +++ b/tests/cmds/search/test_extraction.py @@ -1,7 +1,6 @@ import pytest from c42eventextractor.extractors import BaseExtractor -from py42.response import Py42Response -from requests import Response +from tests.conftest import create_mock_response from code42cli import errors from code42cli.cmds.search.cursor_store import BaseCursorStore @@ -10,7 +9,6 @@ from code42cli.cmds.search.extraction import try_get_default_header from code42cli.output_formats import OutputFormat - key = "events" @@ -59,10 +57,9 @@ def _get_timestamp_from_item(self, item): handlers = create_handlers( sdk, TestExtractor, cursor_store, "chk-name", formatter, force_pager=False ) - http_response = mocker.MagicMock(spec=Response) events = [{"property": "bar"}] - http_response.text = f'{{"{key}": [{{"property": "bar"}}]}}' - py42_response = Py42Response(http_response) + data = f'{{"{key}": [{{"property": "bar"}}]}}' + py42_response = create_mock_response(mocker, data=data) handlers.handle_response(py42_response) formatter.echo_formatted_list.assert_called_once_with(events, force_pager=False) @@ -82,9 +79,8 @@ def _get_timestamp_from_item(self, item): handlers = create_send_to_handlers( sdk, TestExtractor, cursor_store, "chk-name", event_extractor_logger ) - http_response = mocker.MagicMock(spec=Response) events = [{"property": "bar"}] - http_response.text = f'{{"{key}": [{{"property": "bar"}}]}}' - py42_response = Py42Response(http_response) + data = f'{{"{key}": [{{"property": "bar"}}]}}' + py42_response = create_mock_response(mocker, data=data) handlers.handle_response(py42_response) event_extractor_logger.info.assert_called_once_with(events[0]) diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index bdb84554c..2fd678913 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -1,14 +1,14 @@ -import json import logging import pytest from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42InternalServerError from py42.exceptions import Py42InvalidRuleOperationError -from py42.response import Py42Response from requests import HTTPError from requests import Request from requests import Response +from tests.conftest import create_mock_http_error +from tests.conftest import create_mock_response from code42cli.main import cli @@ -16,11 +16,8 @@ TEST_USER_ID = "test-user-id" TEST_USERNAME = "test@example.com" TEST_SOURCE = "rule source" - TEST_EMPTY_RULE_RESPONSE = {"ruleMetadata": []} - ALERT_RULES_COMMAND = "alert-rules" - TEST_RULE_RESPONSE = { "ruleMetadata": [ { @@ -33,7 +30,6 @@ } ] } - TEST_GET_ALL_RESPONSE_EXFILTRATION = { "ruleMetadata": [ {"observerRuleId": TEST_RULE_ID, "type": "FED_ENDPOINT_EXFILTRATION"} @@ -51,19 +47,14 @@ def get_rule_not_found_side_effect(mocker): def side_effect(*args, **kwargs): - response = mocker.MagicMock(spec=Response) - response.text = json.dumps(TEST_EMPTY_RULE_RESPONSE) - return Py42Response(response) + return create_mock_response(mocker, data=TEST_EMPTY_RULE_RESPONSE) return side_effect def get_user_not_on_alert_rule_side_effect(mocker): def side_effect(*args, **kwargs): - err = mocker.MagicMock(spec=HTTPError) - resp = mocker.MagicMock(spec=Response) - resp.text = "TEST_ERR" - err.response = resp + err = create_mock_http_error(mocker, data="TEST_ERR", status=400) raise Py42BadRequestError(err) return side_effect @@ -71,10 +62,7 @@ def side_effect(*args, **kwargs): def create_invalid_rule_type_side_effect(mocker): def side_effect(*args, **kwargs): - err = mocker.MagicMock(spec=HTTPError) - resp = mocker.MagicMock(spec=Response) - resp.text = "TEST_ERR" - err.response = resp + err = create_mock_http_error(mocker, data="TEST_ERR", status=400) raise Py42InvalidRuleOperationError(err, TEST_RULE_ID, TEST_SOURCE) return side_effect diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 9e49f4e58..c2ec8ab78 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -5,12 +5,11 @@ import pytest from c42eventextractor.extractors import AlertExtractor from py42.exceptions import Py42NotFoundError -from py42.response import Py42Response from py42.sdk.queries.alerts.filters import AlertState -from requests import Response from tests.cmds.conftest import filter_term_is_in_call_args from tests.cmds.conftest import get_filter_value_from_json from tests.cmds.conftest import get_mark_for_search_and_send_to +from tests.conftest import create_mock_response from tests.conftest import get_test_date_str from code42cli import errors @@ -344,9 +343,7 @@ def send_to_logger_factory(mocker): @pytest.fixture def full_alert_details_response(mocker): - response = mocker.MagicMock(spec=Response) - response.text = json.dumps(ALERT_DETAILS_FULL_RESPONSE) - return Py42Response(response) + return create_mock_response(mocker, data=ALERT_DETAILS_FULL_RESPONSE) @search_and_send_to_test @@ -992,11 +989,11 @@ def test_show_when_alert_has_note_includes_note( def test_show_when_alert_has_no_note_excludes_note( mocker, cli_state, runner, full_alert_details_response ): - response = mocker.MagicMock(spec=Response) - sans_note_text = dict(ALERT_DETAILS_FULL_RESPONSE) - sans_note_text["alerts"][0]["note"] = None - response.text = json.dumps(sans_note_text) - cli_state.sdk.alerts.get_details.return_value = Py42Response(response) + response_data = dict(ALERT_DETAILS_FULL_RESPONSE) + response_data["alerts"][0]["note"] = None + cli_state.sdk.alerts.get_details.return_value = create_mock_response( + mocker, data=response_data + ) result = runner.invoke(cli, ["alerts", "show", "TEST-ALERT-ID"], obj=cli_state) # Note is included in `full_alert_details_response` initially. assert "Note" not in result.output @@ -1037,11 +1034,11 @@ def test_show_when_alert_has_observations_and_excludes_observations_does_not_out def test_show_when_alert_does_not_have_observations_and_includes_observations_outputs_no_observations( mocker, cli_state, runner ): - response = mocker.MagicMock(spec=Response) - response_text = dict(ALERT_DETAILS_FULL_RESPONSE) - response_text["alerts"][0]["observations"] = None - response.text = json.dumps(response_text) - cli_state.sdk.alerts.get_details.return_value = Py42Response(response) + response_data = dict(ALERT_DETAILS_FULL_RESPONSE) + response_data["alerts"][0]["observations"] = None + cli_state.sdk.alerts.get_details.return_value = create_mock_response( + mocker, data=response_data + ) result = runner.invoke( cli, ["alerts", "show", "TEST-ALERT-ID", "--include-observations"], diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index 032209f11..c35af3ab9 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -105,10 +105,10 @@ def send_to_logger(mocker, send_to_logger_factory): @pytest.fixture def mock_audit_log_response(mocker): response1 = create_mock_response( - mocker, json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) + mocker, data={"events": TEST_EVENTS_WITH_SAME_TIMESTAMP} ) response2 = create_mock_response( - mocker, json.dumps({"events": TEST_EVENTS_WITH_DIFFERENT_TIMESTAMPS}) + mocker, data={"events": TEST_EVENTS_WITH_DIFFERENT_TIMESTAMPS} ) def response_gen(): @@ -120,10 +120,10 @@ def response_gen(): @pytest.fixture def mock_audit_log_response_with_10_records(mocker): - text = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) + data = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) responses = [] for _ in range(0, 10): - responses.append(create_mock_response(mocker, text)) + responses.append(create_mock_response(mocker, data=data)) def response_gen(): yield from responses @@ -133,10 +133,10 @@ def response_gen(): @pytest.fixture def mock_audit_log_response_with_only_same_timestamps(mocker): - text = json.dumps({"events": TEST_EVENTS_WITH_SAME_TIMESTAMP}) + data = {"events": TEST_EVENTS_WITH_SAME_TIMESTAMP} def response_gen(): - yield create_mock_response(mocker, text) + yield create_mock_response(mocker, data=data) return response_gen() @@ -146,10 +146,9 @@ def mock_audit_log_response_with_missing_ms_timestamp(mocker): event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) event["timestamp"] = "2020-01-01T12:00:00Z" response_data = {"events": [event]} - text = json.dumps(response_data) def response_gen(): - yield create_mock_response(mocker, text) + yield create_mock_response(mocker, data=response_data) return response_gen() @@ -158,11 +157,9 @@ def response_gen(): def mock_audit_log_response_with_micro_seconds(mocker): event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) event["timestamp"] = "2021-07-01T14:47:13.093616Z" - response_data = {"events": [event]} - text = json.dumps(response_data) def response_gen(): - yield create_mock_response(mocker, text) + yield create_mock_response(mocker, data={"events": [event]}) return response_gen() @@ -171,11 +168,9 @@ def response_gen(): def mock_audit_log_response_with_nano_seconds(mocker): event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) event["timestamp"] = "2021-07-01T14:47:13.093616500Z" - response_data = {"events": [event]} - text = json.dumps(response_data) def response_gen(): - yield create_mock_response(mocker, text) + yield create_mock_response(mocker, data={"events": [event]}) return response_gen() @@ -185,13 +180,12 @@ def mock_audit_log_response_with_error_causing_timestamp(mocker): good_event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) bad_event = dict(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) bad_event["timestamp"] = "I AM NOT A TIMESTAMP" # Will cause a ValueError. - response_data = { - "events": [good_event, bad_event] - } # good_event should still get processed. - text = json.dumps(response_data) + + # good_event should still get processed. + response_data = {"events": [good_event, bad_event]} def response_gen(): - yield create_mock_response(mocker, text) + yield create_mock_response(mocker, data=response_data) return response_gen() diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 824b50304..3eec6a4e7 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -10,8 +10,7 @@ from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42NotFoundError -from py42.response import Py42Response -from requests import Response +from tests.conftest import create_mock_response from code42cli.cmds.devices import _add_backup_set_settings_to_dataframe from code42cli.cmds.devices import _add_legal_hold_membership_to_device_dataframe @@ -311,14 +310,6 @@ } -def _create_py42_response(mocker, text): - response = mocker.MagicMock(spec=Response) - response.text = text - response._content_consumed = mocker.MagicMock() - response.status_code = 200 - return Py42Response(response) - - @pytest.fixture def mock_device_settings(mocker, mock_backup_set): device_settings = mocker.MagicMock() @@ -342,12 +333,12 @@ def mock_backup_set(mocker): @pytest.fixture def empty_successful_response(mocker): - return _create_py42_response(mocker, "") + return create_mock_response(mocker) @pytest.fixture def device_info_response(mocker): - return _create_py42_response(mocker, TEST_DEVICE_RESPONSE) + return create_mock_response(mocker, data=TEST_DEVICE_RESPONSE) def archives_list_generator(): @@ -372,12 +363,12 @@ def custodian_list_generator(): @pytest.fixture def backupusage_response(mocker): - return _create_py42_response(mocker, TEST_BACKUPUSAGE_RESPONSE) + return create_mock_response(mocker, data=TEST_BACKUPUSAGE_RESPONSE) @pytest.fixture def empty_backupusage_response(mocker): - return _create_py42_response(mocker, TEST_EMPTY_BACKUPUSAGE_RESPONSE) + return create_mock_response(mocker, data=TEST_EMPTY_BACKUPUSAGE_RESPONSE) @pytest.fixture @@ -870,7 +861,7 @@ def test_bulk_deactivate_uses_handler_that_when_encounters_error_increments_tota def _get(guid): if guid == "test": raise Exception("TEST") - return _create_py42_response(mocker, TEST_DEVICE_RESPONSE) + return create_mock_response(mocker, data=TEST_DEVICE_RESPONSE) cli_state.sdk.devices.get_by_guid.side_effect = _get bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") @@ -923,7 +914,7 @@ def test_bulk_reactivate_uses_handler_that_when_encounters_error_increments_tota def _get(guid): if guid == "test": raise Exception("TEST") - return _create_py42_response(mocker, TEST_DEVICE_RESPONSE) + return create_mock_response(mocker, data=TEST_DEVICE_RESPONSE) cli_state.sdk.devices.get_by_guid.side_effect = _get bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 393ecdaca..c0e227530 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -1,14 +1,14 @@ -import json - import pytest -from py42.response import Py42Response -from requests import Response +from py42.exceptions import Py42InvalidEmailError +from py42.exceptions import Py42InvalidPasswordError +from py42.exceptions import Py42InvalidUsernameError +from tests.conftest import create_mock_http_error +from tests.conftest import create_mock_response from code42cli.main import cli from code42cli.worker import WorkerStats _NAMESPACE = "code42cli.cmds.users" - TEST_ROLE_RETURN_DATA = { "data": [{"roleName": "Customer Cloud Admin", "roleId": "1234543"}] } @@ -68,41 +68,33 @@ } -def _create_py42_response(mocker, text): - response = mocker.MagicMock(spec=Response) - response.text = text - response._content_consumed = mocker.MagicMock() - response.status_code = 200 - return Py42Response(response) - - def get_all_users_generator(): yield TEST_USERS_RESPONSE @pytest.fixture def update_user_response(mocker): - return _create_py42_response(mocker, "") + return create_mock_response(mocker) @pytest.fixture def get_available_roles_response(mocker): - return _create_py42_response(mocker, json.dumps(TEST_ROLE_RETURN_DATA)) + return create_mock_response(mocker, data=TEST_ROLE_RETURN_DATA) @pytest.fixture def get_users_response(mocker): - return _create_py42_response(mocker, json.dumps(TEST_USERS_RESPONSE)) + return create_mock_response(mocker, data=TEST_USERS_RESPONSE) @pytest.fixture def change_org_response(mocker): - return _create_py42_response(mocker, "") + return create_mock_response(mocker) @pytest.fixture def get_org_response(mocker): - return _create_py42_response(mocker, json.dumps(TEST_GET_ORG_RESPONSE)) + return create_mock_response(mocker, data=TEST_GET_ORG_RESPONSE) @pytest.fixture @@ -123,8 +115,8 @@ def get_user_id_success(cli_state, get_users_response): @pytest.fixture def get_user_id_failure(mocker, cli_state): - cli_state.sdk.users.get_by_username.return_value = _create_py42_response( - mocker, json.dumps(TEST_EMPTY_USERS_RESPONSE) + cli_state.sdk.users.get_by_username.return_value = create_mock_response( + mocker, data=TEST_EMPTY_USERS_RESPONSE ) @@ -203,8 +195,8 @@ def test_list_when_table_format_outputs_expected_columns( def test_list_users_calls_users_get_all_with_expected_role_id( runner, cli_state, get_available_roles_success, get_all_users_success ): - ROLE_NAME = "Customer Cloud Admin" - runner.invoke(cli, ["users", "list", "--role-name", ROLE_NAME], obj=cli_state) + role_name = "Customer Cloud Admin" + runner.invoke(cli, ["users", "list", "--role-name", role_name], obj=cli_state) cli_state.sdk.users.get_all.assert_called_once_with( active=None, org_uid=None, role_id="1234543" ) @@ -404,6 +396,43 @@ def test_update_user_calls_update_user_with_correct_parameters_when_all_are_pass ) +def test_update_when_py42_raises_invalid_email_outputs_error_message( + mocker, runner, cli_state, update_user_success +): + test_email = "test_email" + mock_http_error = create_mock_http_error(mocker, status=500) + cli_state.sdk.users.update_user.side_effect = Py42InvalidEmailError( + test_email, mock_http_error + ) + command = ["users", "update", "--user-id", "12345", "--email", test_email] + result = runner.invoke(cli, command, obj=cli_state) + assert "Error: 'test_email' is not a valid email." in result.output + + +def test_update_when_py42_raises_invalid_username_outputs_error_message( + mocker, runner, cli_state, update_user_success +): + mock_http_error = create_mock_http_error(mocker, status=500) + cli_state.sdk.users.update_user.side_effect = Py42InvalidUsernameError( + mock_http_error + ) + command = ["users", "update", "--user-id", "12345", "--username", "test_username"] + result = runner.invoke(cli, command, obj=cli_state) + assert "Error: Invalid username." in result.output + + +def test_update_when_py42_raises_invalid_password_outputs_error_message( + mocker, runner, cli_state, update_user_success +): + mock_http_error = create_mock_http_error(mocker, status=500) + cli_state.sdk.users.update_user.side_effect = Py42InvalidPasswordError( + mock_http_error + ) + command = ["users", "update", "--user-id", "12345", "--password", "test_password"] + result = runner.invoke(cli, command, obj=cli_state) + assert "Error: Invalid password." in result.output + + def test_bulk_update_uses_expected_arguments_when_only_some_are_passed( runner, mocker, cli_state ): @@ -505,7 +534,7 @@ def test_bulk_update_uses_handler_that_when_encounters_error_increments_total_er def _update(user_id, *args, **kwargs): if user_id == "12345": raise Exception("TEST") - return _create_py42_response(mocker, TEST_USERS_RESPONSE) + return create_mock_response(mocker, data=TEST_USERS_RESPONSE) cli_state.sdk.users.update_user.side_effect = _update bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") diff --git a/tests/conftest.py b/tests/conftest.py index c179261d4..141e260e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import json from datetime import datetime from datetime import timedelta @@ -5,6 +6,7 @@ from click.testing import CliRunner from py42.response import Py42Response from py42.sdk import SDKClient +from requests import HTTPError from requests import Response import code42cli.errors as error_tracker @@ -285,10 +287,20 @@ def mock_dataframe_to_string(mocker): return mocker.patch("pandas.DataFrame.to_string") -def create_mock_response(mocker, text): +def create_mock_response(mocker, data=None, status=200): + if isinstance(data, dict): + data = json.dumps(data) + elif not data: + data = "" response = mocker.MagicMock(spec=Response) - response.text = text - response.status_code = 200 + response.text = data + response.status_code = status response.encoding = None response._content_consumed = "" return Py42Response(response) + + +def create_mock_http_error(mocker, data=None, status=400): + mock_http_error = mocker.MagicMock(spec=HTTPError) + mock_http_error.response = create_mock_response(mocker, data=data, status=status) + return mock_http_error From 9946ade66e948254544514581a8656f5c9c4b9cf Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Thu, 8 Jul 2021 14:36:24 -0500 Subject: [PATCH 260/349] Release prep - 1.8.0 (#307) --- CHANGELOG.md | 4 +--- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c732a60..7f39a77dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.8.0 - 2021-07-08 ### Fixed @@ -33,8 +33,6 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Changed -- `code42 alerts search` now uses microsecond precision when searching by alerts' date observed. - - Now when a user is not found, the error message suggests that it might be because you don't have the necessary permissions. diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 14d9d2f58..29654eec0 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.7.0" +__version__ = "1.8.0" From df8406052c337416e296326cd7e51ec678e504c6 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 14 Jul 2021 10:49:22 -0500 Subject: [PATCH 261/349] explicitly require chardet since requests 2.26.0 no longer depends on it (#308) * explicitly require chardet since requests 2.26.0 no longer depends on it * changelog and bump version --- CHANGELOG.md | 6 ++++++ setup.py | 1 + src/code42cli/__version__.py | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f39a77dd..0f383748e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.8.1 - 2021-07-14 + +### Fixed + +- The `chardet` library is now an explicit dependency, resolving dependency issues for fresh installations using latest `requests` v.2.26.0 + ## 1.8.0 - 2021-07-08 ### Fixed diff --git a/setup.py b/setup.py index 777ffd69e..be1e8d1ba 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ zip_safe=False, python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4", install_requires=[ + "chardet>=4.0.0", "click>=7.1.1, <8", "click_plugins>=1.1.1", "colorama>=0.4.3", diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 29654eec0..2d986fc50 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.8.0" +__version__ = "1.8.1" From 7a49f7ad9d15910a9b8c680acba11607575c70cf Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 12 Aug 2021 13:51:47 -0500 Subject: [PATCH 262/349] Bugfix/2FA logic (#310) * update 2fa logic * add --totp option to profile cmds, fix `profile delete` error for missing profile * update user-guide MFA note, CHANGELOG * update user-guide MFA note, CHANGELOG * depend on yet-to-be-released py42 1.17 * empty * add --totp/--debug tests in profile cmds, make TOTP type to validate token at argument parsing step * use tuple index instead of property for call_args_list * correct changelog * set TOTP metavar and re-order options * style --- CHANGELOG.md | 14 +++++ docs/userguides/profile.md | 5 +- setup.py | 2 +- src/code42cli/click_ext/types.py | 17 +++++ src/code42cli/cmds/profile.py | 41 +++++++----- src/code42cli/options.py | 2 + src/code42cli/sdk_client.py | 17 +++-- tests/cmds/test_profile.py | 103 +++++++++++++++++++++++-------- tests/conftest.py | 1 + tests/test_sdk_client.py | 20 +++--- 10 files changed, 162 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f383748e..0c34e54d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- `code42 profile` commands that validate passwords (`create`, `update`, `reset-pw`) now have the `--debug` option available, and `create` and `update` can now also pass in `--totp` as an option. + +### Changed + +- A TOTP token is now required on `code42 profile` commands that check for password validity when a user has MFA enabled. + +### Fixed + +- `code42 profile delete` command now prints a clear error message when deletion target doesn't exist. + ## 1.8.1 - 2021-07-14 ### Fixed diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md index c6cd5cb49..d1e248d40 100644 --- a/docs/userguides/profile.md +++ b/docs/userguides/profile.md @@ -36,6 +36,5 @@ code42 profile list ## Profiles with Multi-Factor Authentication -If your Code42 user account requires multi-factor authentication, the token is not required to create your profile but -will be required for any subsequent CLI commands. The MFA token can either be passed in with the `--totp` option, or if -not passed you will be prompted to enter it before the command executes. +If your Code42 user account requires multi-factor authentication, the MFA token can either be passed in with the `--totp` +option, or if not passed you will be prompted to enter it before the command executes. diff --git a/setup.py b/setup.py index be1e8d1ba..9645fad06 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ "keyrings.alt==3.2.0", "ipython>=7.16.1", "pandas>=1.1.3", - "py42>=1.16.0", + "py42>=1.17.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/click_ext/types.py b/src/code42cli/click_ext/types.py index d0d6f679f..b483c1e71 100644 --- a/src/code42cli/click_ext/types.py +++ b/src/code42cli/click_ext/types.py @@ -148,3 +148,20 @@ def convert(self, value, param, ctx): if value in self.extras_map: value = self.extras_map[value] return super().convert(value, param, ctx) + + +class TOTP(click.ParamType): + """Validates param to be a 6-digit integer, which is what all Code42 TOTP tokens will be.""" + + def get_metavar(self, param): + return "TEXT" + + def convert(self, value, param, ctx): + try: + int(value) + assert len(value) == 6 + return value + except Exception: + raise BadParameter( + f"TOTP tokens should be a 6-digit integer. '{value}' was provided." + ) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index b79548a3a..5f84b77c4 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -3,9 +3,9 @@ import click from click import echo from click import secho -from py42.exceptions import Py42MFARequiredError import code42cli.profile as cliprofile +from code42cli.click_ext.types import TOTP from code42cli.errors import Code42CLIError from code42cli.options import yes_option from code42cli.profile import CREATE_PROFILE_HELP @@ -19,6 +19,14 @@ def profile(): pass +debug_option = click.option( + "-d", "--debug", is_flag=True, help="Turn on debug logging.", +) +totp_option = click.option( + "--totp", help="TOTP token for multi-factor authentication.", type=TOTP() +) + + def profile_name_arg(required=False): return click.argument("profile_name", required=required) @@ -85,13 +93,15 @@ def show(profile_name): @server_option(required=True) @username_option(required=True) @password_option +@totp_option @yes_option(hidden=True) @disable_ssl_option -def create(name, server, username, password, disable_ssl_errors): +@debug_option +def create(name, server, username, password, disable_ssl_errors, debug, totp): """Create profile settings. The first profile created will be the default.""" cliprofile.create_profile(name, server, username, disable_ssl_errors) if password: - _set_pw(name, password) + _set_pw(name, password, debug, totp=totp) else: _prompt_for_allow_password_set(name) echo(f"Successfully created profile '{name}'.") @@ -102,8 +112,10 @@ def create(name, server, username, password, disable_ssl_errors): @server_option() @username_option() @password_option +@totp_option @disable_ssl_option -def update(name, server, username, password, disable_ssl_errors): +@debug_option +def update(name, server, username, password, disable_ssl_errors, debug, totp): """Update an existing profile.""" c42profile = cliprofile.get_profile(name) @@ -115,7 +127,7 @@ def update(name, server, username, password, disable_ssl_errors): cliprofile.update_profile(c42profile.name, server, username, disable_ssl_errors) if password: - _set_pw(name, password) + _set_pw(name, password, debug, totp=totp) elif not c42profile.has_stored_password: _prompt_for_allow_password_set(c42profile.name) @@ -124,12 +136,13 @@ def update(name, server, username, password, disable_ssl_errors): @profile.command() @profile_name_arg() -def reset_pw(profile_name): +@debug_option +def reset_pw(profile_name, debug): """\b Change the stored password for a profile. Only affects what's stored in the local profile, does not make any changes to the Code42 user account.""" password = getpass() - profile_name_saved = _set_pw(profile_name, password) + profile_name_saved = _set_pw(profile_name, password, debug) echo(f"Password updated for profile '{profile_name_saved}'.") @@ -156,6 +169,10 @@ def use(profile_name): @profile_name_arg(required=True) def delete(profile_name): """Deletes a profile and its stored password (if any).""" + try: + cliprofile.get_profile(profile_name) + except Code42CLIError: + raise Code42CLIError(f"Profile '{profile_name}' does not exist.") message = ( "\nDeleting this profile will also delete any stored passwords and checkpoints. " "Are you sure? (y/n): " @@ -191,17 +208,13 @@ def delete_all(): def _prompt_for_allow_password_set(profile_name): if does_user_agree("Would you like to set a password? (y/n): "): password = getpass() - _set_pw(profile_name, password) + _set_pw(profile_name, password, False) -def _set_pw(profile_name, password): +def _set_pw(profile_name, password, debug, totp=None): c42profile = cliprofile.get_profile(profile_name) try: - create_sdk(c42profile, is_debug_mode=False, password=password) - except Py42MFARequiredError: - echo( - "Multi-factor account detected. `--totp ` option will be required for all code42 invocations." - ) + create_sdk(c42profile, is_debug_mode=debug, password=password, totp=totp) except Exception: secho("Password not stored!", bold=True) raise diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 065db335f..bf4f1a4fe 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -1,6 +1,7 @@ import click from code42cli.click_ext.types import MagicDate +from code42cli.click_ext.types import TOTP from code42cli.cmds.search.options import AdvancedQueryAndSavedSearchIncompatible from code42cli.cmds.search.options import BeginOption from code42cli.date_helper import convert_datetime_to_timestamp @@ -115,6 +116,7 @@ def debug_option(hidden=False): def totp_option(hidden=False): opt = click.option( "--totp", + type=TOTP(), expose_value=False, callback=set_totp, hidden=hidden, diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index fecb3fc5b..514b837c6 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -4,11 +4,11 @@ import requests from click import prompt from click import secho -from py42.exceptions import Py42MFARequiredError from py42.exceptions import Py42UnauthorizedError from requests.exceptions import ConnectionError from requests.exceptions import SSLError +from code42cli.click_ext.types import TOTP from code42cli.errors import Code42CLIError from code42cli.errors import LoggedCLIError from code42cli.logger import get_main_cli_logger @@ -47,13 +47,18 @@ def _validate_connection(authority_url, username, password, totp=None): except ConnectionError as err: logger.log_error(err) raise LoggedCLIError(f"Problem connecting to {authority_url}.") - except Py42MFARequiredError: - totp = prompt("Multi-factor authentication required. Enter TOTP", type=int) - return _validate_connection(authority_url, username, password, totp) except Py42UnauthorizedError as err: logger.log_error(err) - if "INVALID_TIME_BASED_ONE_TIME_PASSWORD" in err.response.text: - raise Code42CLIError(f"Invalid TOTP token for user {username}.") + if "LoginConfig: LOCAL_2FA" in str(err): + if totp is None: + totp = prompt( + "Multi-factor authentication required. Enter TOTP", type=TOTP() + ) + return _validate_connection(authority_url, username, password, totp) + else: + raise Code42CLIError( + f"Invalid credentials or TOTP token for user {username}." + ) else: raise Code42CLIError(f"Invalid credentials for user {username}.") except Exception as err: diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index e79072dee..b028aff19 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -1,8 +1,5 @@ import pytest -from py42.exceptions import Py42MFARequiredError from py42.sdk import SDKClient -from requests import Response -from requests.exceptions import HTTPError from ..conftest import create_mock_profile from code42cli.errors import Code42CLIError @@ -212,29 +209,6 @@ def test_create_profile_with_password_option_if_credentials_valid_password_saved assert "Would you like to set a password?" not in result.output -def test_create_profile_stores_password_and_prints_message_when_user_requires_mfa( - runner, mocker, mock_verify, mock_cliprofile_namespace -): - mock_verify.side_effect = Py42MFARequiredError(HTTPError(response=Response())) - result = runner.invoke( - cli, - [ - "profile", - "create", - "-n", - "mfa", - "-s", - "bar", - "-u", - "baz", - "--password", - "pass", - ], - ) - assert "Multi-factor account detected." in result.output - mock_cliprofile_namespace.set_password.assert_called_once_with("pass", mocker.ANY) - - def test_create_profile_outputs_confirmation( runner, user_agreement, valid_connection, mock_cliprofile_namespace ): @@ -412,6 +386,11 @@ def test_delete_profile_requires_profile_name_arg(runner, mock_cliprofile_namesp assert mock_cliprofile_namespace.delete_profile.call_count == 0 +def test_delete_profile_raises_CLIError_when_profile_does_not_exist(runner): + result = runner.invoke(cli, ["profile", "delete", "not_a_real_profile"]) + assert result.output == "Error: Profile 'not_a_real_profile' does not exist.\n" + + def test_delete_profile_does_nothing_if_user_doesnt_agree( runner, user_disagreement, mock_cliprofile_namespace ): @@ -530,3 +509,75 @@ def test_use_profile(runner, mock_cliprofile_namespace, profile): profile.name ) assert f"{profile.name} has been set as the default profile." in result.output + + +def test_totp_option_passes_token_to_sdk_on_profile_cmds_that_init_sdk( + runner, mocker, mock_cliprofile_namespace, cli_state +): + totp1 = "123456" + totp2 = "234567" + mock_create_sdk = mocker.patch("code42cli.cmds.profile.create_sdk") + runner.invoke( + cli, + [ + "profile", + "create", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--password", + "testpass", + "--totp", + totp1, + ], + obj=cli_state, + ) + runner.invoke( + cli, + [ + "profile", + "update", + "-n", + "foo", + "--password", + "updatedpass", + "--totp", + totp2, + ], + obj=cli_state, + ) + assert mock_create_sdk.call_args_list[0][1]["totp"] == totp1 + assert mock_create_sdk.call_args_list[1][1]["totp"] == totp2 + + +def test_debug_option_passed_to_sdk_on_profile_cmds_that_init_sdk( + runner, mocker, mock_cliprofile_namespace, cli_state +): + mock_create_sdk = mocker.patch("code42cli.cmds.profile.create_sdk") + runner.invoke( + cli, + [ + "profile", + "create", + "-n", + "foo", + "-s", + "bar", + "-u", + "baz", + "--password", + "testpass", + "--debug", + ], + obj=cli_state, + ) + runner.invoke( + cli, + ["profile", "update", "-n", "foo", "--password", "updatedpass", "--debug"], + obj=cli_state, + ) + assert mock_create_sdk.call_args_list[0][1]["is_debug_mode"] is True + assert mock_create_sdk.call_args_list[1][1]["is_debug_mode"] is True diff --git a/tests/conftest.py b/tests/conftest.py index 141e260e1..4fbcaa8b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -122,6 +122,7 @@ def cli_state(mocker, sdk, profile): mock_state._sdk = sdk mock_state.profile = profile mock_state.search_filters = [] + mock_state.totp = None mock_state.assume_yes = False return mock_state diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index f7f8b75db..b67666a1b 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -3,7 +3,6 @@ import py42.sdk import py42.settings.debug as debug import pytest -from py42.exceptions import Py42MFARequiredError from py42.exceptions import Py42UnauthorizedError from requests import Response from requests.exceptions import ConnectionError @@ -117,15 +116,14 @@ def test_create_sdk_uses_given_credentials( ) -def test_create_sdk_connection_when_mfa_required_exception_raised_prompts_for_totp( +def test_create_sdk_connection_when_2FA_login_config_detected_prompts_for_totp( mocker, monkeypatch, mock_sdk_factory, capsys, mock_profile_with_password ): monkeypatch.setattr("sys.stdin", StringIO("101010")) response = mocker.MagicMock(spec=Response) - mock_sdk_factory.side_effect = [ - Py42MFARequiredError(HTTPError(response=response)), - None, - ] + exception = Py42UnauthorizedError(HTTPError(response=response)) + exception.args = ("LoginConfig: LOCAL_2FA",) + mock_sdk_factory.side_effect = [exception, None] create_sdk(mock_profile_with_password, False) output = capsys.readouterr() assert "Multi-factor authentication required. Enter TOTP:" in output.out @@ -135,11 +133,13 @@ def test_create_sdk_connection_when_mfa_token_invalid_raises_expected_cli_error( mocker, mock_sdk_factory, mock_profile_with_password ): response = mocker.MagicMock(spec=Response) - response.text = '{"data":null,"error":[{"primaryErrorKey":"INVALID_TIME_BASED_ONE_TIME_PASSWORD","otherErrors":null}],"warnings":null}' - mock_sdk_factory.side_effect = Py42UnauthorizedError(HTTPError(response=response)) + exception = Py42UnauthorizedError(HTTPError(response=response)) + error_text = "SDK initialization failed, double-check username/password, and provide two-factor TOTP token if Multi-Factor Auth configured for your user. User LoginConfig: LOCAL_2FA" + exception.args = (error_text,) + mock_sdk_factory.side_effect = exception with pytest.raises(Code42CLIError) as err: create_sdk(mock_profile_with_password, False, totp="1234") - assert str(err.value) == "Invalid TOTP token for user foo." + assert str(err.value) == "Invalid credentials or TOTP token for user foo." def test_totp_option_when_passed_is_passed_to_sdk_initialization( @@ -147,7 +147,7 @@ def test_totp_option_when_passed_is_passed_to_sdk_initialization( ): mock_py42 = mocker.patch("code42cli.sdk_client.py42.sdk.from_local_account") cli_state = CLIState() - totp = "1234" + totp = "123456" profile.authority_url = "example.com" profile.username = "user" profile.get_password.return_value = "password" From de4aabd82088fa7e70759b2ad52e5dc729fcd875 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 16 Aug 2021 10:32:28 -0500 Subject: [PATCH 263/349] don't specify chardet version (#312) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9645fad06..db2e7bed7 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ zip_safe=False, python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4", install_requires=[ - "chardet>=4.0.0", + "chardet", "click>=7.1.1, <8", "click_plugins>=1.1.1", "colorama>=0.4.3", From 40ed44816feeb35db0f2cc48592c607f8660b662 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 18 Aug 2021 09:10:10 -0500 Subject: [PATCH 264/349] Refactor password prompts to allow for --debug setting (#313) * refactor password prompts to allow for debug setting * style --- src/code42cli/cmds/profile.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 5f84b77c4..c1ec0a117 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -100,10 +100,9 @@ def show(profile_name): def create(name, server, username, password, disable_ssl_errors, debug, totp): """Create profile settings. The first profile created will be the default.""" cliprofile.create_profile(name, server, username, disable_ssl_errors) + password = password or _prompt_for_password(name) if password: _set_pw(name, password, debug, totp=totp) - else: - _prompt_for_allow_password_set(name) echo(f"Successfully created profile '{name}'.") @@ -126,10 +125,10 @@ def update(name, server, username, password, disable_ssl_errors, debug, totp): ) cliprofile.update_profile(c42profile.name, server, username, disable_ssl_errors) + if not password and not c42profile.has_stored_password: + password = _prompt_for_password(c42profile.name) if password: _set_pw(name, password, debug, totp=totp) - elif not c42profile.has_stored_password: - _prompt_for_allow_password_set(c42profile.name) echo(f"Profile '{c42profile.name}' has been updated.") @@ -205,10 +204,10 @@ def delete_all(): echo("\nNo profiles exist. Nothing to delete.") -def _prompt_for_allow_password_set(profile_name): +def _prompt_for_password(profile_name): if does_user_agree("Would you like to set a password? (y/n): "): password = getpass() - _set_pw(profile_name, password, False) + return password def _set_pw(profile_name, password, debug, totp=None): From 0e68738864d2149687f96a892331326f465c8944 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Wed, 18 Aug 2021 11:46:06 -0500 Subject: [PATCH 265/349] Feature/query for risk indicators (#311) * added risk severity and indicator querying * fixed a string * fixed a string * updated changelog and imports * styling * -risk-indicator error message now displays ALL_CAPS_ARGS * use reverse mapping to use ALL_CAPS_VALUES for risk-indicator option * replace deprecated option ':show-nested:' --- CHANGELOG.md | 4 + docs/commands/alertrules.rst | 2 +- docs/commands/alerts.rst | 2 +- docs/commands/auditlogs.rst | 2 +- docs/commands/cases.rst | 2 +- docs/commands/departingemployee.rst | 2 +- docs/commands/devices.rst | 2 +- docs/commands/highriskemployee.rst | 2 +- docs/commands/legalhold.rst | 2 +- docs/commands/profile.rst | 2 +- docs/commands/securitydata.rst | 2 +- docs/commands/users.rst | 2 +- src/code42cli/click_ext/types.py | 1 + src/code42cli/cmds/securitydata.py | 100 +++++++++++++++++++++ tests/cmds/test_securitydata.py | 132 ++++++++++++++++++++++++++++ 15 files changed, 248 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c34e54d5..db49b8e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 profile` commands that validate passwords (`create`, `update`, `reset-pw`) now have the `--debug` option available, and `create` and `update` can now also pass in `--totp` as an option. +- New command options for `code42 security-data search` + - `--risk-indicator` to filter events by risk indicators. + - `--risk-severity` to filter events by risk severity. + ### Changed - A TOTP token is now required on `code42 profile` commands that check for password validity when a user has MFA enabled. diff --git a/docs/commands/alertrules.rst b/docs/commands/alertrules.rst index 2b89041e9..d8f2507c5 100644 --- a/docs/commands/alertrules.rst +++ b/docs/commands/alertrules.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.alert_rules:alert_rules :prog: alert-rules - :show-nested: + :nested: full diff --git a/docs/commands/alerts.rst b/docs/commands/alerts.rst index 45e5cf249..4c39ea8bc 100644 --- a/docs/commands/alerts.rst +++ b/docs/commands/alerts.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.alerts:alerts :prog: alerts - :show-nested: + :nested: full diff --git a/docs/commands/auditlogs.rst b/docs/commands/auditlogs.rst index c2191b271..29eb0e462 100644 --- a/docs/commands/auditlogs.rst +++ b/docs/commands/auditlogs.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.auditlogs:audit_logs :prog: audit-logs - :show-nested: + :nested: full diff --git a/docs/commands/cases.rst b/docs/commands/cases.rst index 714bf34f4..ac124f0a5 100644 --- a/docs/commands/cases.rst +++ b/docs/commands/cases.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.cases:cases :prog: cases - :show-nested: + :nested: full diff --git a/docs/commands/departingemployee.rst b/docs/commands/departingemployee.rst index 56c4cf8cd..a1c3ac5e5 100644 --- a/docs/commands/departingemployee.rst +++ b/docs/commands/departingemployee.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.departing_employee:departing_employee :prog: departing-employee - :show-nested: + :nested: full diff --git a/docs/commands/devices.rst b/docs/commands/devices.rst index d0012b9cf..79d477237 100644 --- a/docs/commands/devices.rst +++ b/docs/commands/devices.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.devices:devices :prog: devices - :show-nested: + :nested: full diff --git a/docs/commands/highriskemployee.rst b/docs/commands/highriskemployee.rst index 8ca35d318..4fd75700b 100644 --- a/docs/commands/highriskemployee.rst +++ b/docs/commands/highriskemployee.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.high_risk_employee:high_risk_employee :prog: high-risk-employee - :show-nested: + :nested: full diff --git a/docs/commands/legalhold.rst b/docs/commands/legalhold.rst index 36a87bc87..e6c1598a0 100644 --- a/docs/commands/legalhold.rst +++ b/docs/commands/legalhold.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.legal_hold:legal_hold :prog: legal-hold - :show-nested: + :nested: full diff --git a/docs/commands/profile.rst b/docs/commands/profile.rst index 73f4ca712..a8f7d1675 100644 --- a/docs/commands/profile.rst +++ b/docs/commands/profile.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.profile:profile :prog: profile - :show-nested: + :nested: full diff --git a/docs/commands/securitydata.rst b/docs/commands/securitydata.rst index 76e908269..d00966b9c 100644 --- a/docs/commands/securitydata.rst +++ b/docs/commands/securitydata.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.securitydata:security_data :prog: security-data - :show-nested: + :nested: full diff --git a/docs/commands/users.rst b/docs/commands/users.rst index e63a60b96..59dc0c20c 100644 --- a/docs/commands/users.rst +++ b/docs/commands/users.rst @@ -1,3 +1,3 @@ .. click:: code42cli.cmds.users:users :prog: users - :show-nested: + :nested: full diff --git a/src/code42cli/click_ext/types.py b/src/code42cli/click_ext/types.py index b483c1e71..6cc9bda17 100644 --- a/src/code42cli/click_ext/types.py +++ b/src/code42cli/click_ext/types.py @@ -147,6 +147,7 @@ def __init__(self, choices, extras_map, **kwargs): def convert(self, value, param, ctx): if value in self.extras_map: value = self.extras_map[value] + return super().convert(value, param, ctx) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 33ebbc61f..7c5354aab 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -6,6 +6,8 @@ from click import echo from py42.sdk.queries.fileevents.filters.exposure_filter import ExposureType from py42.sdk.queries.fileevents.filters.file_filter import FileCategory +from py42.sdk.queries.fileevents.filters.risk_filter import RiskIndicator +from py42.sdk.queries.fileevents.filters.risk_filter import RiskSeverity import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt @@ -143,6 +145,100 @@ cls=incompatible_with(["advanced_query", "type", "saved_search"]), help="Get all events including non-exposure events.", ) +risk_indicator_map = { + "PUBLIC_CORPORATE_BOX": RiskIndicator.CloudDataExposures.PUBLIC_CORPORATE_BOX, + "PUBLIC_CORPORATE_GOOGLE": RiskIndicator.CloudDataExposures.PUBLIC_CORPORATE_GOOGLE_DRIVE, + "PUBLIC_CORPORATE_ONEDRIVE": RiskIndicator.CloudDataExposures.PUBLIC_CORPORATE_ONEDRIVE, + "SENT_CORPORATE_GMAIL": RiskIndicator.CloudDataExposures.SENT_CORPORATE_GMAIL, + "SHARED_CORPORATE_BOX": RiskIndicator.CloudDataExposures.SHARED_CORPORATE_BOX, + "SHARED_CORPORATE_GOOGLE_DRIVE": RiskIndicator.CloudDataExposures.SHARED_CORPORATE_GOOGLE_DRIVE, + "SHARED_CORPORATE_ONEDRIVE": RiskIndicator.CloudDataExposures.SHARED_CORPORATE_ONEDRIVE, + "AMAZON_DRIVE": RiskIndicator.CloudStorageUploads.AMAZON_DRIVE, + "BOX": RiskIndicator.CloudStorageUploads.BOX, + "DROPBOX": RiskIndicator.CloudStorageUploads.DROPBOX, + "GOOGLE_DRIVE": RiskIndicator.CloudStorageUploads.GOOGLE_DRIVE, + "ICLOUD": RiskIndicator.CloudStorageUploads.ICLOUD, + "MEGA": RiskIndicator.CloudStorageUploads.MEGA, + "ONEDRIVE": RiskIndicator.CloudStorageUploads.ONEDRIVE, + "ZOHO": RiskIndicator.CloudStorageUploads.ZOHO, + "BITBUCKET": RiskIndicator.CodeRepositoryUploads.BITBUCKET, + "GITHUB": RiskIndicator.CodeRepositoryUploads.GITHUB, + "GITLAB": RiskIndicator.CodeRepositoryUploads.GITLAB, + "SOURCEFORGE": RiskIndicator.CodeRepositoryUploads.SOURCEFORGE, + "STASH": RiskIndicator.CodeRepositoryUploads.STASH, + "163.COM": RiskIndicator.EmailServiceUploads.ONESIXTHREE_DOT_COM, + "126.COM": RiskIndicator.EmailServiceUploads.ONETWOSIX_DOT_COM, + "AOL": RiskIndicator.EmailServiceUploads.AOL, + "COMCAST": RiskIndicator.EmailServiceUploads.COMCAST, + "GMAIL": RiskIndicator.EmailServiceUploads.GMAIL, + "ICLOUD_MAIL": RiskIndicator.EmailServiceUploads.ICLOUD, + "MAIL.COM": RiskIndicator.EmailServiceUploads.MAIL_DOT_COM, + "OUTLOOK": RiskIndicator.EmailServiceUploads.OUTLOOK, + "PROTONMAIL": RiskIndicator.EmailServiceUploads.PROTONMAIL, + "QQMAIL": RiskIndicator.EmailServiceUploads.QQMAIL, + "SINA_MAIL": RiskIndicator.EmailServiceUploads.SINA_MAIL, + "SOHU_MAIl": RiskIndicator.EmailServiceUploads.SOHU_MAIl, + "YAHOO": RiskIndicator.EmailServiceUploads.YAHOO, + "ZOHO_MAIL": RiskIndicator.EmailServiceUploads.ZOHO_MAIL, + "AIRDROP": RiskIndicator.ExternalDevices.AIRDROP, + "REMOVABLE_MEDIA": RiskIndicator.ExternalDevices.REMOVABLE_MEDIA, + "AUDIO": RiskIndicator.FileCategories.AUDIO, + "DOCUMENT": RiskIndicator.FileCategories.DOCUMENT, + "EXECUTABLE": RiskIndicator.FileCategories.EXECUTABLE, + "IMAGE": RiskIndicator.FileCategories.IMAGE, + "PDF": RiskIndicator.FileCategories.PDF, + "PRESENTATION": RiskIndicator.FileCategories.PRESENTATION, + "SCRIPT": RiskIndicator.FileCategories.SCRIPT, + "SOURCE_CODE": RiskIndicator.FileCategories.SOURCE_CODE, + "SPREADSHEET": RiskIndicator.FileCategories.SPREADSHEET, + "VIDEO": RiskIndicator.FileCategories.VIDEO, + "VIRTUAL_DISK_IMAGE": RiskIndicator.FileCategories.VIRTUAL_DISK_IMAGE, + "ZIP": RiskIndicator.FileCategories.ZIP, + "FACEBOOK_MESSENGER": RiskIndicator.MessagingServiceUploads.FACEBOOK_MESSENGER, + "MICROSOFT_TEAMS": RiskIndicator.MessagingServiceUploads.MICROSOFT_TEAMS, + "SLACK": RiskIndicator.MessagingServiceUploads.SLACK, + "WHATSAPP": RiskIndicator.MessagingServiceUploads.WHATSAPP, + "OTHER": RiskIndicator.Other.OTHER, + "UNKNOWN": RiskIndicator.Other.UNKNOWN, + "FACEBOOK": RiskIndicator.SocialMediaUploads.FACEBOOK, + "LINKEDIN": RiskIndicator.SocialMediaUploads.LINKEDIN, + "REDDIT": RiskIndicator.SocialMediaUploads.REDDIT, + "TWITTER": RiskIndicator.SocialMediaUploads.TWITTER, + "FILE_MISMATCH": RiskIndicator.UserBehavior.FILE_MISMATCH, + "OFF_HOURS": RiskIndicator.UserBehavior.OFF_HOURS, + "REMOTE": RiskIndicator.UserBehavior.REMOTE, +} +risk_indicator_map_reversed = {v: k for k, v in risk_indicator_map.items()} + + +def risk_indicator_callback(filter_cls): + def callback(ctx, param, arg): + if arg: + mapped_arg = (risk_indicator_map[arg[0]],) + filter_func = searchopt.is_in_filter(filter_cls) + return filter_func(ctx, param, mapped_arg) + + return callback + + +risk_indicator_option = click.option( + "--risk-indicator", + multiple=True, + type=MapChoice( + choices=list(risk_indicator_map.keys()), extras_map=risk_indicator_map_reversed, + ), + callback=risk_indicator_callback(f.RiskIndicator), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to those classified by the given risk indicator categories.", +) +risk_severity_option = click.option( + "--risk-severity", + multiple=True, + type=click.Choice(list(RiskSeverity.choices())), + callback=searchopt.is_in_filter(f.RiskSeverity), + cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, + help="Limits events to those classified by the given risk severity.", +) begin_option = opt.begin_option( SECURITY_DATA_KEYWORD, callback=lambda ctx, param, arg: convert_datetime_to_timestamp( @@ -186,6 +282,8 @@ def _create_search_header_map(): "fileOwner": "FileOwner", "md5Checksum": "MD5Checksum", "sha256Checksum": "SHA256Checksum", + "riskIndicators": "RiskIndicator", + "riskSeverity": "RiskSeverity", } @@ -210,6 +308,8 @@ def file_event_options(f): f = process_owner_option(f) f = tab_url_option(f) f = include_non_exposure_option(f) + f = risk_indicator_option(f) + f = risk_severity_option(f) f = _get_saved_search_option()(f) return f diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 91dfd3cb0..762b04f99 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -5,6 +5,8 @@ import pytest from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.file_event_query import FileEventQuery +from py42.sdk.queries.fileevents.filters import RiskIndicator +from py42.sdk.queries.fileevents.filters import RiskSeverity from py42.sdk.queries.fileevents.filters.file_filter import FileCategory from tests.cmds.conftest import filter_term_is_in_call_args from tests.cmds.conftest import get_filter_value_from_json @@ -107,6 +109,8 @@ ("--tab-url", "https://example.com"), ("--type", "SharedViaLink"), ("--include-non-exposure",), + ("--risk-indicator", "PUBLIC_CORPORATE_BOX"), + ("--risk-severity", "LOW"), ], ) saved_search_incompat_test_params = pytest.mark.parametrize( @@ -127,6 +131,8 @@ ("--type", "SharedViaLink"), ("--include-non-exposure",), ("--use-checkpoint", "test"), + ("--risk-indicator", "PUBLIC_CORPORATE_BOX"), + ("--risk-severity", "LOW"), ], ) search_and_send_to_test = get_mark_for_search_and_send_to("security-data") @@ -789,6 +795,132 @@ def test_search_and_send_to_when_given_include_non_exposure_and_exposure_types_c assert result.exit_code == 2 +@search_and_send_to_test +def test_search_and_send_to_when_given_risk_indicator_uses_risk_indicator_filter( + runner, cli_state, file_event_extractor, command +): + risk_indicator = RiskIndicator.MessagingServiceUploads.SLACK + command = [*command, "--begin", "1h", "--risk-indicator", risk_indicator] + runner.invoke( + cli, command, obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(f.RiskIndicator.is_in([risk_indicator])) in filter_strings + + +@pytest.mark.parametrize( + "indicator_choice", + [ + ("PUBLIC_CORPORATE_BOX", RiskIndicator.CloudDataExposures.PUBLIC_CORPORATE_BOX), + ( + "PUBLIC_CORPORATE_GOOGLE", + RiskIndicator.CloudDataExposures.PUBLIC_CORPORATE_GOOGLE_DRIVE, + ), + ( + "PUBLIC_CORPORATE_ONEDRIVE", + RiskIndicator.CloudDataExposures.PUBLIC_CORPORATE_ONEDRIVE, + ), + ("SENT_CORPORATE_GMAIL", RiskIndicator.CloudDataExposures.SENT_CORPORATE_GMAIL), + ("SHARED_CORPORATE_BOX", RiskIndicator.CloudDataExposures.SHARED_CORPORATE_BOX), + ( + "SHARED_CORPORATE_GOOGLE_DRIVE", + RiskIndicator.CloudDataExposures.SHARED_CORPORATE_GOOGLE_DRIVE, + ), + ( + "SHARED_CORPORATE_ONEDRIVE", + RiskIndicator.CloudDataExposures.SHARED_CORPORATE_ONEDRIVE, + ), + ("AMAZON_DRIVE", RiskIndicator.CloudStorageUploads.AMAZON_DRIVE), + ("BOX", RiskIndicator.CloudStorageUploads.BOX), + ("DROPBOX", RiskIndicator.CloudStorageUploads.DROPBOX), + ("GOOGLE_DRIVE", RiskIndicator.CloudStorageUploads.GOOGLE_DRIVE), + ("ICLOUD", RiskIndicator.CloudStorageUploads.ICLOUD), + ("MEGA", RiskIndicator.CloudStorageUploads.MEGA), + ("ONEDRIVE", RiskIndicator.CloudStorageUploads.ONEDRIVE), + ("ZOHO", RiskIndicator.CloudStorageUploads.ZOHO), + ("BITBUCKET", RiskIndicator.CodeRepositoryUploads.BITBUCKET), + ("GITHUB", RiskIndicator.CodeRepositoryUploads.GITHUB), + ("GITLAB", RiskIndicator.CodeRepositoryUploads.GITLAB), + ("SOURCEFORGE", RiskIndicator.CodeRepositoryUploads.SOURCEFORGE), + ("STASH", RiskIndicator.CodeRepositoryUploads.STASH), + ("163.COM", RiskIndicator.EmailServiceUploads.ONESIXTHREE_DOT_COM), + ("126.COM", RiskIndicator.EmailServiceUploads.ONETWOSIX_DOT_COM), + ("AOL", RiskIndicator.EmailServiceUploads.AOL), + ("COMCAST", RiskIndicator.EmailServiceUploads.COMCAST), + ("GMAIL", RiskIndicator.EmailServiceUploads.GMAIL), + ("ICLOUD_MAIL", RiskIndicator.EmailServiceUploads.ICLOUD), + ("MAIL.COM", RiskIndicator.EmailServiceUploads.MAIL_DOT_COM), + ("OUTLOOK", RiskIndicator.EmailServiceUploads.OUTLOOK), + ("PROTONMAIL", RiskIndicator.EmailServiceUploads.PROTONMAIL), + ("QQMAIL", RiskIndicator.EmailServiceUploads.QQMAIL), + ("SINA_MAIL", RiskIndicator.EmailServiceUploads.SINA_MAIL), + ("SOHU_MAIl", RiskIndicator.EmailServiceUploads.SOHU_MAIl), + ("YAHOO", RiskIndicator.EmailServiceUploads.YAHOO), + ("ZOHO_MAIL", RiskIndicator.EmailServiceUploads.ZOHO_MAIL), + ("AIRDROP", RiskIndicator.ExternalDevices.AIRDROP), + ("REMOVABLE_MEDIA", RiskIndicator.ExternalDevices.REMOVABLE_MEDIA), + ("AUDIO", RiskIndicator.FileCategories.AUDIO), + ("DOCUMENT", RiskIndicator.FileCategories.DOCUMENT), + ("EXECUTABLE", RiskIndicator.FileCategories.EXECUTABLE), + ("IMAGE", RiskIndicator.FileCategories.IMAGE), + ("PDF", RiskIndicator.FileCategories.PDF), + ("PRESENTATION", RiskIndicator.FileCategories.PRESENTATION), + ("SCRIPT", RiskIndicator.FileCategories.SCRIPT), + ("SOURCE_CODE", RiskIndicator.FileCategories.SOURCE_CODE), + ("SPREADSHEET", RiskIndicator.FileCategories.SPREADSHEET), + ("VIDEO", RiskIndicator.FileCategories.VIDEO), + ("VIRTUAL_DISK_IMAGE", RiskIndicator.FileCategories.VIRTUAL_DISK_IMAGE), + ("ZIP", RiskIndicator.FileCategories.ZIP), + ( + "FACEBOOK_MESSENGER", + RiskIndicator.MessagingServiceUploads.FACEBOOK_MESSENGER, + ), + ("MICROSOFT_TEAMS", RiskIndicator.MessagingServiceUploads.MICROSOFT_TEAMS), + ("SLACK", RiskIndicator.MessagingServiceUploads.SLACK), + ("WHATSAPP", RiskIndicator.MessagingServiceUploads.WHATSAPP), + ("OTHER", RiskIndicator.Other.OTHER), + ("UNKNOWN", RiskIndicator.Other.UNKNOWN), + ("FACEBOOK", RiskIndicator.SocialMediaUploads.FACEBOOK), + ("LINKEDIN", RiskIndicator.SocialMediaUploads.LINKEDIN), + ("REDDIT", RiskIndicator.SocialMediaUploads.REDDIT), + ("TWITTER", RiskIndicator.SocialMediaUploads.TWITTER), + ("FILE_MISMATCH", RiskIndicator.UserBehavior.FILE_MISMATCH), + ("OFF_HOURS", RiskIndicator.UserBehavior.OFF_HOURS), + ("REMOTE", RiskIndicator.UserBehavior.REMOTE), + ], +) +def test_all_caps_risk_indicator_choices_convert_to_risk_indicator_string( + runner, cli_state, file_event_extractor, indicator_choice +): + ALL_CAPS_VALUE, string_value = indicator_choice + command = [ + "security-data", + "search", + "--begin", + "1h", + "--risk-indicator", + ALL_CAPS_VALUE, + ] + runner.invoke( + cli, command, obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(f.RiskIndicator.is_in([string_value])) in filter_strings + + +@search_and_send_to_test +def test_search_and_send_to_when_given_risk_severity_uses_risk_severity_filter( + runner, cli_state, file_event_extractor, command +): + risk_severity = RiskSeverity.LOW + command = [*command, "--begin", "1h", "--risk-severity", risk_severity] + runner.invoke( + cli, command, obj=cli_state, + ) + filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] + assert str(f.RiskSeverity.is_in([risk_severity])) in filter_strings + + @search_and_send_to_test def test_search_and_send_to_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( runner, cli_state, caplog, command From 699d4cf366d7369ad1b64a8fce726477b4daee59 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Thu, 19 Aug 2021 09:05:09 -0500 Subject: [PATCH 266/349] Feature/query for risk indicators (#314) * added risk severity and indicator querying * fixed a string * fixed a string * updated changelog and imports * styling * -risk-indicator error message now displays ALL_CAPS_ARGS * use reverse mapping to use ALL_CAPS_VALUES for risk-indicator option * replace deprecated option ':show-nested:' * allow for multiple --risk-indicator flags * simplifying code --- src/code42cli/cmds/securitydata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 7c5354aab..856625059 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -214,9 +214,9 @@ def risk_indicator_callback(filter_cls): def callback(ctx, param, arg): if arg: - mapped_arg = (risk_indicator_map[arg[0]],) + mapped_args = tuple(risk_indicator_map[i] for i in arg) filter_func = searchopt.is_in_filter(filter_cls) - return filter_func(ctx, param, mapped_arg) + return filter_func(ctx, param, mapped_args) return callback From 33e3d03ea2387ef9ae07a59f3df5427816b57111 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Thu, 19 Aug 2021 13:31:12 -0500 Subject: [PATCH 267/349] Feature/new risk indicator (#315) * added new risk-indicator filter values * adding new filter values * Update CHANGELOG.md --- CHANGELOG.md | 2 ++ setup.py | 2 +- src/code42cli/cmds/securitydata.py | 4 +++- tests/cmds/test_securitydata.py | 4 +++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db49b8e36..ab5bf01d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - A TOTP token is now required on `code42 profile` commands that check for password validity when a user has MFA enabled. +- Updated minimum version of py42 to `1.18.0` to provide access to `FIRST_DESTINATION_USE` and `RARE_DESTINATION_USE` search filters. + ### Fixed - `code42 profile delete` command now prints a clear error message when deletion target doesn't exist. diff --git a/setup.py b/setup.py index db2e7bed7..a2d0457b0 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ "keyrings.alt==3.2.0", "ipython>=7.16.1", "pandas>=1.1.3", - "py42>=1.17.0", + "py42>=1.18.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 856625059..e0cd582cb 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -177,7 +177,7 @@ "PROTONMAIL": RiskIndicator.EmailServiceUploads.PROTONMAIL, "QQMAIL": RiskIndicator.EmailServiceUploads.QQMAIL, "SINA_MAIL": RiskIndicator.EmailServiceUploads.SINA_MAIL, - "SOHU_MAIl": RiskIndicator.EmailServiceUploads.SOHU_MAIl, + "SOHU_MAIL": RiskIndicator.EmailServiceUploads.SOHU_MAIL, "YAHOO": RiskIndicator.EmailServiceUploads.YAHOO, "ZOHO_MAIL": RiskIndicator.EmailServiceUploads.ZOHO_MAIL, "AIRDROP": RiskIndicator.ExternalDevices.AIRDROP, @@ -207,6 +207,8 @@ "FILE_MISMATCH": RiskIndicator.UserBehavior.FILE_MISMATCH, "OFF_HOURS": RiskIndicator.UserBehavior.OFF_HOURS, "REMOTE": RiskIndicator.UserBehavior.REMOTE, + "FIRST_DESTINATION_USE": RiskIndicator.UserBehavior.FIRST_DESTINATION_USE, + "RARE_DESTINATION_USE": RiskIndicator.UserBehavior.RARE_DESTINATION_USE, } risk_indicator_map_reversed = {v: k for k, v in risk_indicator_map.items()} diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 762b04f99..1e5adf137 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -854,7 +854,7 @@ def test_search_and_send_to_when_given_risk_indicator_uses_risk_indicator_filter ("PROTONMAIL", RiskIndicator.EmailServiceUploads.PROTONMAIL), ("QQMAIL", RiskIndicator.EmailServiceUploads.QQMAIL), ("SINA_MAIL", RiskIndicator.EmailServiceUploads.SINA_MAIL), - ("SOHU_MAIl", RiskIndicator.EmailServiceUploads.SOHU_MAIl), + ("SOHU_MAIL", RiskIndicator.EmailServiceUploads.SOHU_MAIL), ("YAHOO", RiskIndicator.EmailServiceUploads.YAHOO), ("ZOHO_MAIL", RiskIndicator.EmailServiceUploads.ZOHO_MAIL), ("AIRDROP", RiskIndicator.ExternalDevices.AIRDROP), @@ -887,6 +887,8 @@ def test_search_and_send_to_when_given_risk_indicator_uses_risk_indicator_filter ("FILE_MISMATCH", RiskIndicator.UserBehavior.FILE_MISMATCH), ("OFF_HOURS", RiskIndicator.UserBehavior.OFF_HOURS), ("REMOTE", RiskIndicator.UserBehavior.REMOTE), + ("FIRST_DESTINATION_USE", RiskIndicator.UserBehavior.FIRST_DESTINATION_USE), + ("RARE_DESTINATION_USE", RiskIndicator.UserBehavior.RARE_DESTINATION_USE), ], ) def test_all_caps_risk_indicator_choices_convert_to_risk_indicator_string( From 02a922401b0bf08b403bbd19f91d33eaf950b173 Mon Sep 17 00:00:00 2001 From: Ryan Haley <87095328+ryan-haley-code42@users.noreply.github.com> Date: Thu, 19 Aug 2021 14:03:41 -0500 Subject: [PATCH 268/349] Release prep 1.9.0 (#316) * minor version update * update changelog for release 1.9.0 * update changelog to remove unreleased section * correcting changelog version for latestest --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5bf01d2..da84eb4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.9.0 - 2021-08-19 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 2d986fc50..0a0a43a57 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.8.1" +__version__ = "1.9.0" From fe448b16dde2ad38b3af4c953aacae6b6a5f2d4c Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 27 Aug 2021 14:17:47 -0500 Subject: [PATCH 269/349] users deactivate and reactivate commands (#317) * users deactivate and reactivate commands * d>r Co-authored-by: tim.abramson --- CHANGELOG.md | 10 ++ src/code42cli/click_ext/groups.py | 2 + src/code42cli/cmds/users.py | 124 +++++++++++++++++++++-- tests/cmds/test_users.py | 162 ++++++++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da84eb4a4..808c2b192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- New commands: + - `code42 users deactivate` + - `code42 users reactivate` + - `code42 users bulk deactivate` + - `code42 users bulk reactivate` + ## 1.9.0 - 2021-08-19 ### Added diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index f8eba17e6..9b072d5ee 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -3,6 +3,7 @@ from collections import OrderedDict import click +from py42.exceptions import Py42ActiveLegalHoldError from py42.exceptions import Py42CaseAlreadyHasEventError from py42.exceptions import Py42CaseNameExistsError from py42.exceptions import Py42DescriptionLimitExceededError @@ -75,6 +76,7 @@ def invoke(self, ctx): Py42InvalidEmailError, Py42InvalidPasswordError, Py42InvalidUsernameError, + Py42ActiveLegalHoldError, ) as err: self.logger.log_error(err) raise Code42CLIError(str(err)) diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index a8a5666ab..3cd30810d 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -36,12 +36,9 @@ def users(state): help="Limits results to only deactivated users.", cls=incompatible_with("active"), ) - - -user_uid_option = click.option( +user_id_option = click.option( "--user-id", help="The unique identifier of the user to be modified.", required=True ) - org_id_option = click.option( "--org-id", help="The identifier for the organization to which the user will be moved.", @@ -101,7 +98,7 @@ def remove_role(state, username, role_name): @users.command(name="update") -@user_uid_option +@user_id_option @click.option("--username", help="The new username for the user.") @click.option("--password", help="The new password for the user.") @click.option("--email", help="The new email for the user.") @@ -137,6 +134,24 @@ def update_user( ) +@users.command() +@click.argument("username") +@sdk_options() +def deactivate(state, username): + """Deactivate a user.""" + sdk = state.sdk + _deactivate_user(sdk, username) + + +@users.command() +@click.argument("username") +@sdk_options() +def reactivate(state, username): + """Reactivate a user.""" + sdk = state.sdk + _reactivate_user(sdk, username) + + _bulk_user_update_headers = [ "user_id", "username", @@ -259,19 +274,100 @@ def handle_row(**row): formatter.echo_formatted_list(result_rows) +_bulk_user_activation_headers = ["username"] + + +@bulk.command( + name="deactivate", + help=f"Deactivate a list of users from the provided CSV in format: {','.join(_bulk_user_activation_headers)}", +) +@read_csv_arg(headers=_bulk_user_activation_headers) +@format_option +@sdk_options() +def bulk_deactivate(state, csv_rows, format): + """Deactivate a list of users.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + + csv_rows[0]["deactivated"] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) + + def handle_row(**row): + try: + _deactivate_user( + sdk, **{key: row[key] for key in row.keys() if key != "deactivated"} + ) + row["deactivated"] = "True" + except Exception as err: + row["deactivated"] = f"False: {err}" + stats.increment_total_errors() + return row + + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Deactivating users:", + stats=stats, + raise_global_error=False, + ) + formatter.echo_formatted_list(result_rows) + + +@bulk.command( + name="reactivate", + help=f"Reactivate a list of users from the provided CSV in format: {','.join(_bulk_user_activation_headers)}", +) +@read_csv_arg(headers=_bulk_user_activation_headers) +@format_option +@sdk_options() +def bulk_reactivate(state, csv_rows, format): + """Reactivate a list of users.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + + csv_rows[0]["reactivated"] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) + + def handle_row(**row): + try: + _reactivate_user( + sdk, **{key: row[key] for key in row.keys() if key != "reactivated"} + ) + row["reactivated"] = "True" + except Exception as err: + row["reactivated"] = f"False: {err}" + stats.increment_total_errors() + return row + + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Reactivating users:", + stats=stats, + raise_global_error=False, + ) + formatter.echo_formatted_list(result_rows) + + def _add_user_role(sdk, username, role_name): - user_id = _get_user_id(sdk, username) + user_id = _get_legacy_user_id(sdk, username) _get_role_id(sdk, role_name) # function provides role name validation sdk.users.add_role(user_id, role_name) def _remove_user_role(sdk, role_name, username): - user_id = _get_user_id(sdk, username) + user_id = _get_legacy_user_id(sdk, username) _get_role_id(sdk, role_name) # function provides role name validation sdk.users.remove_role(user_id, role_name) -def _get_user_id(sdk, username): +def _get_legacy_user_id(sdk, username): if not username: # py42 returns all users when passing `None` to `get_by_username()`. raise click.BadParameter("Username is required.") @@ -326,7 +422,7 @@ def _update_user( def _change_organization(sdk, username, org_id): - user_id = _get_user_id(sdk, username) + user_id = _get_legacy_user_id(sdk, username) org_id = _get_org_id(sdk, org_id) return sdk.users.change_org_assignment(user_id=int(user_id), org_id=int(org_id)) @@ -334,3 +430,13 @@ def _change_organization(sdk, username, org_id): def _get_org_id(sdk, org_id): org = sdk.orgs.get_by_uid(org_id) return org["orgId"] + + +def _deactivate_user(sdk, username): + user_id = _get_legacy_user_id(sdk, username) + sdk.users.deactivate(user_id) + + +def _reactivate_user(sdk, username): + user_id = _get_legacy_user_id(sdk, username) + sdk.users.reactivate(user_id) diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index c0e227530..4362ca840 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -1,4 +1,5 @@ import pytest +from py42.exceptions import Py42ActiveLegalHoldError from py42.exceptions import Py42InvalidEmailError from py42.exceptions import Py42InvalidPasswordError from py42.exceptions import Py42InvalidUsernameError @@ -130,6 +131,23 @@ def update_user_success(cli_state, update_user_response): cli_state.sdk.users.update_user.return_value = update_user_response +@pytest.fixture +def deactivate_user_success(mocker, cli_state): + cli_state.sdk.users.deactivate.return_value = create_mock_response(mocker) + + +@pytest.fixture +def deactivate_user_legal_hold_failure(mocker, cli_state): + cli_state.sdk.users.deactivate.side_effect = Py42ActiveLegalHoldError( + create_mock_http_error(mocker, status=400), "user", TEST_USER_ID + ) + + +@pytest.fixture +def reactivate_user_success(mocker, cli_state): + cli_state.sdk.users.deactivate.return_value = create_mock_response(mocker) + + @pytest.fixture def change_org_success(cli_state, change_org_response): cli_state.sdk.users.change_org_assignment.return_value = change_org_response @@ -433,6 +451,33 @@ def test_update_when_py42_raises_invalid_password_outputs_error_message( assert "Error: Invalid password." in result.output +def test_deactivate_calls_deactivate_with_correct_parameters( + runner, cli_state, get_user_id_success, deactivate_user_success +): + command = ["users", "deactivate", "test@example.com"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.deactivate.assert_called_once_with(TEST_USER_ID) + + +def test_deactivate_when_user_on_legal_hold_outputs_expected_error_text( + runner, cli_state, get_user_id_success, deactivate_user_legal_hold_failure +): + command = ["users", "deactivate", "test@example.com"] + result = runner.invoke(cli, command, obj=cli_state) + assert ( + "Error: Cannot deactivate the user with ID 1234 as the user is involved in a legal hold matter." + in result.output + ) + + +def test_reactivate_calls_reactivate_with_correct_parameters( + runner, cli_state, get_user_id_success, deactivate_user_success +): + command = ["users", "reactivate", "test@example.com"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.users.reactivate.assert_called_once_with(TEST_USER_ID) + + def test_bulk_update_uses_expected_arguments_when_only_some_are_passed( runner, mocker, cli_state ): @@ -655,3 +700,120 @@ def test_bulk_move_uses_handle_than_when_called_and_row_has_missing_username_err assert worker_stats.increment_total_errors.call_count == 1 # Ensure it does not try to get the username for the None user. assert not cli_state.sdk.users.get_by_username.call_count + + +def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_deactivate.csv", "w") as csv: + csv.writelines(["username\n", f"{TEST_USERNAME}\n"]) + runner.invoke( + cli, + ["users", "bulk", "deactivate", "test_bulk_deactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "deactivated": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_deactivate_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_deactivate.csv", "w") as csv: + csv.writelines(["username\n\n\n", f"{TEST_USERNAME}\n\n\n"]) + runner.invoke( + cli, + ["users", "bulk", "deactivate", "test_bulk_deactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "deactivated": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_deactivate_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats, get_users_response +): + lines = ["username\n", f"{TEST_USERNAME}\n"] + + def _get(username, *args, **kwargs): + if username == "test@example.com": + raise Exception("TEST") + return get_users_response + + cli_state.sdk.users.get_by_username.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_deactivate.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, + ["users", "bulk", "deactivate", "test_bulk_deactivate.csv"], + obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler(username="test@example.com") + handler(username="not.test@example.com") + assert worker_stats.increment_total_errors.call_count == 1 + + +def test_bulk_reactivate_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_reactivate.csv", "w") as csv: + csv.writelines(["username\n", f"{TEST_USERNAME}\n"]) + runner.invoke( + cli, + ["users", "bulk", "reactivate", "test_bulk_reactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "reactivated": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_reactivate_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_reactivate.csv", "w") as csv: + csv.writelines(["username\n\n\n", f"{TEST_USERNAME}\n\n\n"]) + runner.invoke( + cli, + ["users", "bulk", "reactivate", "test_bulk_reactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "reactivated": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_reactivate_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats, get_users_response +): + lines = ["username\n", f"{TEST_USERNAME}\n"] + + def _get(username, *args, **kwargs): + if username == "test@example.com": + raise Exception("TEST") + + return get_users_response + + cli_state.sdk.users.get_by_username.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_reactivate.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, + ["users", "bulk", "reactivate", "test_bulk_reactivate.csv"], + obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler(username="test@example.com") + handler(username="not.test@example.com") + assert worker_stats.increment_total_errors.call_count == 1 From 4ca646da4f02e37033b7c8f7e5858eebb627c46b Mon Sep 17 00:00:00 2001 From: maddie-vargo <75453991+maddie-vargo@users.noreply.github.com> Date: Fri, 27 Aug 2021 14:25:42 -0500 Subject: [PATCH 270/349] Users command: option to report on legal hold membership (#290) * Command option to report on user legal hold membership and tests * Fixing conlicts * fix style on users.py * updated branch to match main * Adding changes to meet tests comments * use create_mock_respose * finish using create_mock_response * black Co-authored-by: Juliya Smith Co-authored-by: Tim Abramson --- CHANGELOG.md | 2 + src/code42cli/cmds/users.py | 51 ++++++++++++- tests/cmds/test_users.py | 141 ++++++++++++++++++++++++++++++++++-- 3 files changed, 188 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808c2b192..d1a8eb4fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- New option `--include-legal-hold-membership` on command `code42 users list` that includes the legal hold matter name and ID for any user on legal hold. + - New commands: - `code42 users deactivate` - `code42 users reactivate` diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 3cd30810d..4975039d8 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -1,5 +1,6 @@ import click from pandas import DataFrame +from pandas import json_normalize from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process @@ -59,9 +60,17 @@ def username_option(help, required=False): @role_name_option("Limit results to only users having the specified role.") @active_option @inactive_option +@click.option( + "--include-legal-hold-membership", + default=False, + is_flag=True, + help="Include legal hold membership in output.", +) @format_option @sdk_options() -def list_users(state, org_uid, role_name, active, inactive, format): +def list_users( + state, org_uid, role_name, active, inactive, include_legal_hold_membership, format +): """List users in your Code42 environment.""" if inactive: active = False @@ -72,6 +81,8 @@ def list_users(state, org_uid, role_name, active, inactive, format): else None ) df = _get_users_dataframe(state.sdk, columns, org_uid, role_id, active) + if include_legal_hold_membership: + df = _add_legal_hold_membership_to_user_dataframe(state.sdk, df) if df.empty: click.echo("No results found.") else: @@ -398,6 +409,44 @@ def _get_users_dataframe(sdk, columns, org_uid, role_id, active): return DataFrame.from_records(users_list, columns=columns) +def _add_legal_hold_membership_to_user_dataframe(sdk, df): + columns = ["legalHold.legalHoldUid", "legalHold.name", "user.userUid"] + + custodians = list(_get_all_active_hold_memberships(sdk)) + if len(custodians) == 0: + return df + + legal_hold_member_dataframe = ( + json_normalize(custodians)[columns] + .groupby(["user.userUid"]) + .agg(",".join) + .rename( + { + "legalHold.legalHoldUid": "legalHoldUid", + "legalHold.name": "legalHoldName", + }, + axis=1, + ) + ) + df = df.merge( + legal_hold_member_dataframe, + how="left", + left_on="userUid", + right_on="user.userUid", + ) + + return df + + +def _get_all_active_hold_memberships(sdk): + for page in sdk.legalhold.get_all_matters(active=True): + for matter in page["legalHolds"]: + for _page in sdk.legalhold.get_all_matter_custodians( + legal_hold_uid=matter["legalHoldUid"], active=True + ): + yield from _page["legalHoldMemberships"] + + def _update_user( sdk, user_id, diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 4362ca840..fbcbf20b9 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -35,6 +35,42 @@ } ] } +TEST_MATTER_RESPONSE = { + "legalHolds": [ + {"legalHoldUid": "123456789", "name": "Legal Hold #1", "active": True}, + {"legalHoldUid": "987654321", "name": "Legal Hold #2", "active": True}, + ] +} +TEST_CUSTODIANS_RESPONSE = { + "legalHoldMemberships": [ + { + "legalHoldMembershipUid": "99999", + "active": True, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": {"legalHoldUid": "123456789", "name": "Legal Hold #1"}, + "user": { + "userUid": "911162111513111325", + "username": "test.username@example.com", + "email": "test.username@example.com", + "userExtRef": None, + }, + }, + { + "legalHoldMembershipUid": "11111", + "active": True, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": {"legalHoldUid": "987654321", "name": "Legal Hold #2"}, + "user": { + "userUid": "911162111513111325", + "username": "test.username@example.com", + "email": "test.username@example.com", + "userExtRef": None, + }, + }, + ] +} +TEST_EMPTY_CUSTODIANS_RESPONSE = {"legalHoldMemberships": []} +TEST_EMPTY_MATTERS_RESPONSE = {"legalHolds": []} TEST_EMPTY_USERS_RESPONSE = {"users": []} TEST_USERNAME = TEST_USERS_RESPONSE["users"][0]["username"] TEST_USER_ID = TEST_USERS_RESPONSE["users"][0]["userId"] @@ -69,10 +105,6 @@ } -def get_all_users_generator(): - yield TEST_USERS_RESPONSE - - @pytest.fixture def update_user_response(mocker): return create_mock_response(mocker) @@ -104,7 +136,10 @@ def get_org_success(cli_state, get_org_response): @pytest.fixture -def get_all_users_success(cli_state): +def get_all_users_success(mocker, cli_state): + def get_all_users_generator(): + yield create_mock_response(mocker, data=TEST_USERS_RESPONSE) + cli_state.sdk.users.get_all.return_value = get_all_users_generator() @@ -121,6 +156,42 @@ def get_user_id_failure(mocker, cli_state): ) +@pytest.fixture +def get_custodian_failure(mocker, cli_state): + def empty_custodian_list_generator(): + yield create_mock_response(mocker, data=TEST_EMPTY_CUSTODIANS_RESPONSE) + + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + empty_custodian_list_generator() + ) + + +@pytest.fixture +def get_matter_failure(mocker, cli_state): + def empty_matter_list_generator(): + yield create_mock_response(mocker, data=TEST_EMPTY_MATTERS_RESPONSE) + + cli_state.sdk.legalhold.get_all_matters.return_value = empty_matter_list_generator() + + +@pytest.fixture +def get_all_matter_success(mocker, cli_state): + def matter_list_generator(): + yield create_mock_response(mocker, data=TEST_MATTER_RESPONSE) + + cli_state.sdk.legalhold.get_all_matters.return_value = matter_list_generator() + + +@pytest.fixture +def get_all_custodian_success(mocker, cli_state): + def custodian_list_generator(): + yield create_mock_response(mocker, data=TEST_CUSTODIANS_RESPONSE) + + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + custodian_list_generator() + ) + + @pytest.fixture def get_available_roles_success(cli_state, get_available_roles_response): cli_state.sdk.users.get_available_roles.return_value = get_available_roles_response @@ -259,6 +330,66 @@ def test_list_users_when_given_excluding_active_and_inactive_uses_active_equals_ ) +def test_list_legal_hold_flag_reports_none_for_users_not_on_legal_hold( + runner, + cli_state, + get_all_users_success, + get_custodian_failure, + get_all_matter_success, +): + result = runner.invoke( + cli, + ["users", "list", "--include-legal-hold-membership", "-f", "CSV"], + obj=cli_state, + ) + + assert "Legal Hold #1,Legal Hold #2" not in result.output + assert "123456789,987654321" not in result.output + assert "legalHoldUid" not in result.output + assert "test.username@example.com" in result.output + + +def test_list_legal_hold_flag_reports_none_if_no_matters_exist( + runner, cli_state, get_all_users_success, get_custodian_failure, get_matter_failure +): + result = runner.invoke( + cli, ["users", "list", "--include-legal-hold-membership"], obj=cli_state + ) + + assert "Legal Hold #1,Legal Hold #2" not in result.output + assert "123456789,987654321" not in result.output + assert "legalHoldUid" not in result.output + assert "test.username@example.com" in result.output + + +def test_list_legal_hold_values_not_included_for_legal_hold_user_if_legal_hold_flag_not_passed( + runner, + cli_state, + get_all_users_success, + get_all_custodian_success, + get_all_matter_success, +): + result = runner.invoke(cli, ["users", "list"], obj=cli_state) + assert "Legal Hold #1,Legal Hold #2" not in result.output + assert "123456789,987654321" not in result.output + assert "test.username@example.com" in result.output + + +def test_list_include_legal_hold_membership_merges_in_and_concats_legal_hold_info( + runner, + cli_state, + get_all_users_success, + get_all_custodian_success, + get_all_matter_success, +): + result = runner.invoke( + cli, ["users", "list", "--include-legal-hold-membership"], obj=cli_state + ) + + assert "Legal Hold #1,Legal Hold #2" in result.output + assert "123456789,987654321" in result.output + + def test_add_user_role_adds( runner, cli_state, get_user_id_success, get_available_roles_success ): From 9ab3328930bddb1985312555ef719d5d4f434797 Mon Sep 17 00:00:00 2001 From: Juliya Smith Date: Fri, 27 Aug 2021 14:51:54 -0500 Subject: [PATCH 271/349] Feature/select profile (#262) * Add select command * add cl * Rm fstr * PR Feedback * CL for profile use change * Add missing test and cl entry * A and The * fstr * refactor * rm conflict markers * add tests for prompt choice class * test prompt text * rm accidental part of cl * Add word was * cond * Doc * spell out var * add eg * rename method * use helper method * doc * rm unused fixtures * rm var from str intp in test * rm extra space * conform modular naming * fix tests * doc * undo unreleated changes * Undo unreleated changes pt2 * hmmm..... * add missing echo * undo and actually add missing echo * correct changelog * clarify Co-authored-by: tim.abramson --- CHANGELOG.md | 4 +++- src/code42cli/click_ext/types.py | 16 ++++++++++++++++ src/code42cli/cmds/auditlogs.py | 1 - src/code42cli/cmds/profile.py | 33 +++++++++++++++++++++++++++++--- src/code42cli/util.py | 14 ++++++++++++++ tests/click_ext/__init__.py | 0 tests/click_ext/test_types.py | 10 ++++++++++ tests/cmds/test_auditlogs.py | 6 ++++-- tests/cmds/test_profile.py | 33 ++++++++++++++++++++++++++++++++ tests/test_util.py | 2 +- 10 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 tests/click_ext/__init__.py create mode 100644 tests/click_ext/test_types.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a8eb4fc..3adc33ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,14 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - New option `--include-legal-hold-membership` on command `code42 users list` that includes the legal hold matter name and ID for any user on legal hold. -- New commands: +- New commands for deactivating/reactivating Code42 user accounts: - `code42 users deactivate` - `code42 users reactivate` - `code42 users bulk deactivate` - `code42 users bulk reactivate` +- `code42 profile use` now prompts you to select a profile when not given a profile name argument. + ## 1.9.0 - 2021-08-19 ### Added diff --git a/src/code42cli/click_ext/types.py b/src/code42cli/click_ext/types.py index 6cc9bda17..87c8b9bf4 100644 --- a/src/code42cli/click_ext/types.py +++ b/src/code42cli/click_ext/types.py @@ -8,6 +8,7 @@ from click.exceptions import BadParameter from code42cli.logger import CliLogger +from code42cli.util import print_numbered_list class AutoDecodedFile(click.File): @@ -151,6 +152,21 @@ def convert(self, value, param, ctx): return super().convert(value, param, ctx) +class PromptChoice(click.ParamType): + def __init__(self, choices): + self.choices = choices + + def print_choices(self): + print_numbered_list(self.choices) + + def convert(self, value, param, ctx): + try: + choice_index = int(value) - 1 + return self.choices[choice_index] + except Exception: + self.fail("Invalid choice", param=param) + + class TOTP(click.ParamType): """Validates param to be a 6-digit integer, which is what all Code42 TOTP tokens will be.""" diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index ba0c2e6f0..4e7df28e3 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -49,7 +49,6 @@ def _get_audit_logs_default_header(): help="Filter results by actor user IDs.", multiple=True, ) - filter_option_user_ip_addresses = click.option( "--actor-ip", required=False, diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index c1ec0a117..756a30bfb 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -5,6 +5,7 @@ from click import secho import code42cli.profile as cliprofile +from code42cli.click_ext.types import PromptChoice from code42cli.click_ext.types import TOTP from code42cli.errors import Code42CLIError from code42cli.options import yes_option @@ -158,9 +159,15 @@ def _list(): @profile.command() @profile_name_arg() def use(profile_name): - """Set a profile as the default.""" - cliprofile.switch_default_profile(profile_name) - echo(f"{profile_name} has been set as the default profile.") + """\b + Set a profile as the default. If not providing a profile-name, + prompts for a choice from a list of all profiles.""" + + if not profile_name: + _select_profile_from_prompt() + return + + _set_default_profile(profile_name) @profile.command() @@ -219,3 +226,23 @@ def _set_pw(profile_name, password, debug, totp=None): raise cliprofile.set_password(password, c42profile.name) return c42profile.name + + +def _select_profile_from_prompt(): + """Set the default profile from user input.""" + profiles = cliprofile.get_all_profiles() + profile_names = [profile_choice.name for profile_choice in profiles] + choices = PromptChoice(profile_names) + choices.print_choices() + prompt_message = "Input the number of the profile you wish to use" + profile_name = click.prompt(prompt_message, type=choices) + _set_default_profile(profile_name) + + +def _set_default_profile(profile_name): + cliprofile.switch_default_profile(profile_name) + _print_default_profile_was_set(profile_name) + + +def _print_default_profile_was_set(profile_name): + echo(f"{profile_name} has been set as the default profile.") diff --git a/src/code42cli/util.py b/src/code42cli/util.py index cdadccaff..fd961d9d9 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -38,6 +38,7 @@ def get_user_project_path(*subdirs): result_path = path.join(user_project_path, *subdirs) if not path.exists(result_path): os.makedirs(result_path) + return result_path @@ -156,6 +157,7 @@ def get_url_parts(url_str): port = None if len(parts) > 1 and parts[1] != "": port = int(parts[1]) + return parts[0], port @@ -170,6 +172,7 @@ def _get_default_header(header_items): for key in keys: if key not in header and isinstance(key, str): header[key] = key + return header @@ -179,6 +182,17 @@ def hash_event(event): return md5(event.encode()).hexdigest() +def print_numbered_list(items): + """Outputs a numbered list of items to the user. + For example, provide ["test", "foo"] to print "1. test\n2. foo". + """ + + choices = dict(enumerate(items, 1)) + for num in choices: + echo(f"{num}. {choices[num]}") + echo() + + def parse_timestamp(date_str): # example: {"property": "bar", "timestamp": "2020-11-23T17:13:26.239647Z"} ts = date_str[:-1] diff --git a/tests/click_ext/__init__.py b/tests/click_ext/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/click_ext/test_types.py b/tests/click_ext/test_types.py new file mode 100644 index 000000000..d74b9ed80 --- /dev/null +++ b/tests/click_ext/test_types.py @@ -0,0 +1,10 @@ +from code42cli.click_ext.types import PromptChoice + + +class TestPromptChoice: + def test_convert_returns_expected_item(self): + choices = ["foo", "bar", "test"] + prompt_choice = PromptChoice(choices) + assert prompt_choice.convert("1", None, None) == "foo" + assert prompt_choice.convert("2", None, None) == "bar" + assert prompt_choice.convert("3", None, None) == "test" diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index c35af3ab9..b256f746c 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -66,7 +66,8 @@ def audit_log_cursor_with_checkpoint(mocker): mock_cursor = mocker.MagicMock(spec=AuditLogCursorStore) mock_cursor.get.return_value = CURSOR_TIMESTAMP mocker.patch( - "code42cli.cmds.auditlogs._get_audit_log_cursor_store", return_value=mock_cursor + "code42cli.cmds.auditlogs._get_audit_log_cursor_store", + return_value=mock_cursor, ) return mock_cursor @@ -79,7 +80,8 @@ def audit_log_cursor_with_checkpoint_and_events(mocker): hash_event(TEST_EVENTS_WITH_SAME_TIMESTAMP[0]) ] mocker.patch( - "code42cli.cmds.auditlogs._get_audit_log_cursor_store", return_value=mock_cursor + "code42cli.cmds.auditlogs._get_audit_log_cursor_store", + return_value=mock_cursor, ) return mock_cursor diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index b028aff19..acc5d396a 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -7,6 +7,9 @@ from code42cli.main import cli +_SELECTED_PROFILE_NAME = "test_profile" + + @pytest.fixture def user_agreement(mocker): mock = mocker.patch("code42cli.cmds.profile.does_user_agree") @@ -50,6 +53,13 @@ def invalid_connection(mock_verify): return mock_verify +@pytest.fixture +def profile_name_selector(mocker): + mock = mocker.patch("code42cli.cmds.profile.click.prompt") + mock.return_value = _SELECTED_PROFILE_NAME + return mock + + def test_show_profile_outputs_profile_info(runner, mock_cliprofile_namespace, profile): profile.name = "testname" profile.authority_url = "example.com" @@ -511,6 +521,29 @@ def test_use_profile(runner, mock_cliprofile_namespace, profile): assert f"{profile.name} has been set as the default profile." in result.output +def test_use_profile_when_not_given_profile_name_arg_sets_selected_profile_as_default( + runner, mock_cliprofile_namespace, profile_name_selector +): + runner.invoke(cli, ["profile", "use"]) + mock_cliprofile_namespace.switch_default_profile.assert_called_once_with( + _SELECTED_PROFILE_NAME + ) + + +def test_use_profile_when_not_given_profile_name_outputs_expected_text( + runner, mock_cliprofile_namespace, profile_name_selector +): + mock_cliprofile_namespace.get_all_profiles.return_value = [ + create_mock_profile("test1"), + create_mock_profile("test2"), + ] + result = runner.invoke(cli, ["profile", "use"]) + expected_prompt = "1. test1\n2. test2" + expected_result_message = "test_profile has been set as the default profile." + assert expected_prompt in result.output + assert expected_result_message in result.output + + def test_totp_option_passes_token_to_sdk_on_profile_cmds_that_init_sdk( runner, mocker, mock_cliprofile_namespace, cli_state ): diff --git a/tests/test_util.py b/tests/test_util.py index 0241aa957..b2a8b2916 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -61,7 +61,7 @@ def test_does_user_agree_when_user_says_n_returns_false( def test_does_user_agree_when_assume_yes_argument_passed_returns_true_and_does_not_print_prompt( - mocker, context_with_assume_yes, capsys + context_with_assume_yes, capsys ): result = does_user_agree("Test Prompt") output = capsys.readouterr() From 81600c672342ccbfcb2c6bd1b1e381c51529ecf9 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 13 Sep 2021 16:05:39 -0500 Subject: [PATCH 272/349] remove broken OutputFormat.echo_formatted_generated_output() (#322) * remove broken OutputFormat.echo_formatted_generated_output() * "no results" message to stderr * revert header params * changelog update * exclude flake8 rule * black --- CHANGELOG.md | 4 ++++ setup.cfg | 2 ++ src/code42cli/cmds/auditlogs.py | 18 ++++++++++-------- src/code42cli/output_formats.py | 13 ------------- tests/cmds/test_auditlogs.py | 17 ----------------- 5 files changed, 16 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3adc33ded..291fb35eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - `code42 profile use` now prompts you to select a profile when not given a profile name argument. +### Fixed + +- Bug where `audit-logs search` with `--use-checkpoint` option was causing output formatting problems. + ## 1.9.0 - 2021-08-19 ### Added diff --git a/setup.cfg b/setup.cfg index 43fd0e55b..58a0a0d03 100644 --- a/setup.cfg +++ b/setup.cfg @@ -27,5 +27,7 @@ ignore = E722 # binary operation line break, different opinion from black W503 + # exception chaining + B904 # up to 88 allowed by bugbear B950 max-line-length = 80 diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index 4e7df28e3..68f843cdc 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -141,18 +141,20 @@ def search( affected_user_ids=affected_user_id, affected_usernames=affected_username, ) - if not events: - click.echo("No results found.") - return if use_checkpoint: checkpoint_name = use_checkpoint - events_gen = _dedupe_checkpointed_events_and_store_updated_checkpoint( - cursor, checkpoint_name, events + events = list( + _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, events + ) ) - formatter.echo_formatted_generated_output(events_gen) - else: - formatter.echo_formatted_list(events) + + if not events: + click.echo("No results found.", err=True) + return + + formatter.echo_formatted_list(events) @audit_logs.command(cls=SendToCommand) diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index e7135f1ea..dc5027f43 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -79,19 +79,6 @@ def echo_formatted_list(self, output_list, force_pager=False): if self.output_format in [OutputFormat.TABLE]: click.echo() - def echo_formatted_generated_output(self, output_generator): - def _gen(): - include_header = True - for output in output_generator: - if output: - formatted_output = self._format_output( - output, include_header=include_header - ) - yield formatted_output - include_header = False - - click.echo_via_pager(_gen) - @property def _requires_list_output(self): return self.output_format in (OutputFormat.TABLE, OutputFormat.CSV) diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index b256f746c..15d131b60 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -586,23 +586,6 @@ def test_search_and_send_when_timestamps_have_nanoseconds_saves_checkpoint( assert call_args[0][1] == 1625150833.093616 -def test_search_if_error_occurs_when_processing_event_timestamp_still_outputs_results( - cli_state, - runner, - mock_audit_log_response_with_error_causing_timestamp, - audit_log_cursor_with_checkpoint, -): - cli_state.sdk.auditlogs.get_all.return_value = ( - mock_audit_log_response_with_error_causing_timestamp - ) - res = runner.invoke( - cli, ["audit-logs", "search", "--use-checkpoint", "test"], obj=cli_state, - ) - assert TEST_AUDIT_LOG_TIMESTAMP_1 in res.output - assert "I AM NOT A TIMESTAMP" in res.output - assert "Error: Unknown problem occurred." in res.output - - def test_search_if_error_occurs_when_processing_event_timestamp_does_not_store_error_timestamp( cli_state, runner, From 4c062804337a5e564151d042d26f6c1a0af86854 Mon Sep 17 00:00:00 2001 From: Peter Briggs Date: Wed, 15 Sep 2021 16:20:52 -0500 Subject: [PATCH 273/349] Bugfix/improve invalid org uid error (#319) * Improve error message for `code42 users list` when supplied org UID is invalid * Improve error message for device commands in event of invalid org UID * Remove per-command exception handling, add it to groups.py instead * Remove unused imports * Bump py42 version to get updated error handling, add tests for devices * Update changelog * rollback blank line delete --- CHANGELOG.md | 1 + setup.py | 2 +- src/code42cli/click_ext/groups.py | 2 ++ tests/cmds/test_devices.py | 35 +++++++++++++++++++++++++++++++ tests/cmds/test_users.py | 17 +++++++++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291fb35eb..5d312339e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Fixed - Bug where `audit-logs search` with `--use-checkpoint` option was causing output formatting problems. +- Improve error message for `code42 users list`, `code42 devices list`, `code42 devices list-backup-sets` ## 1.9.0 - 2021-08-19 diff --git a/setup.py b/setup.py index a2d0457b0..33810564c 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ "keyrings.alt==3.2.0", "ipython>=7.16.1", "pandas>=1.1.3", - "py42>=1.18.0", + "py42>=1.18.1", ], extras_require={ "dev": [ diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index 9b072d5ee..f1912982e 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -14,6 +14,7 @@ from py42.exceptions import Py42InvalidRuleOperationError from py42.exceptions import Py42InvalidUsernameError from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError +from py42.exceptions import Py42OrgNotFoundError from py42.exceptions import Py42UpdateClosedCaseError from py42.exceptions import Py42UserAlreadyAddedError from py42.exceptions import Py42UsernameMustBeEmailError @@ -77,6 +78,7 @@ def invoke(self, ctx): Py42InvalidPasswordError, Py42InvalidUsernameError, Py42ActiveLegalHoldError, + Py42OrgNotFoundError, ) as err: self.logger.log_error(err) raise Code42CLIError(str(err)) diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 3eec6a4e7..4f803b798 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -10,6 +10,7 @@ from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42NotFoundError +from py42.exceptions import Py42OrgNotFoundError from tests.conftest import create_mock_response from code42cli.cmds.devices import _add_backup_set_settings_to_dataframe @@ -725,6 +726,40 @@ def test_list_include_legal_hold_membership_merges_in_and_concats_legal_hold_inf assert "123456789,987654321" in result.output +def test_list_invalid_org_uid_raises_error(runner, cli_state, custom_error): + custom_error.response.text = "Unable to find org" + invalid_org_uid = "invalid_org_uid" + cli_state.sdk.devices.get_all.side_effect = Py42OrgNotFoundError( + custom_error, invalid_org_uid + ) + result = runner.invoke( + cli, ["devices", "list", "--org-uid", invalid_org_uid], obj=cli_state + ) + assert result.exit_code == 1 + assert ( + f"Error: The organization with UID '{invalid_org_uid}' was not found." + in result.output + ) + + +def test_list_backup_sets_invalid_org_uid_raises_error(runner, cli_state, custom_error): + custom_error.response.text = "Unable to find org" + invalid_org_uid = "invalid_org_uid" + cli_state.sdk.devices.get_all.side_effect = Py42OrgNotFoundError( + custom_error, invalid_org_uid + ) + result = runner.invoke( + cli, + ["devices", "list-backup-sets", "--org-uid", invalid_org_uid], + obj=cli_state, + ) + assert result.exit_code == 1 + assert ( + f"Error: The organization with UID '{invalid_org_uid}' was not found." + in result.output + ) + + def test_break_backup_usage_into_total_storage_correctly_calculates_values(): test_backupusage_cell = json.loads(TEST_BACKUPUSAGE_RESPONSE)["data"]["backupUsage"] result = _break_backup_usage_into_total_storage(test_backupusage_cell) diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index fbcbf20b9..1af78ec18 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -3,6 +3,7 @@ from py42.exceptions import Py42InvalidEmailError from py42.exceptions import Py42InvalidPasswordError from py42.exceptions import Py42InvalidUsernameError +from py42.exceptions import Py42OrgNotFoundError from tests.conftest import create_mock_http_error from tests.conftest import create_mock_response @@ -330,6 +331,22 @@ def test_list_users_when_given_excluding_active_and_inactive_uses_active_equals_ ) +def test_list_users_when_given_invalid_org_uid_raises_error( + runner, cli_state, get_available_roles_success, custom_error +): + invalid_org_uid = "invalid_org_uid" + cli_state.sdk.users.get_all.side_effect = Py42OrgNotFoundError( + custom_error, invalid_org_uid + ) + result = runner.invoke( + cli, ["users", "list", "--org-uid", invalid_org_uid], obj=cli_state + ) + assert ( + f"Error: The organization with UID '{invalid_org_uid}' was not found." + in result.output + ) + + def test_list_legal_hold_flag_reports_none_for_users_not_on_legal_hold( runner, cli_state, From 43a5baea467a989390d3a97b407be30ab30cc4ce Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 20 Sep 2021 11:53:18 -0500 Subject: [PATCH 274/349] PATH troubleshooting helpers/docs (#321) * have previous version match command * add __main__.py to enable `python -m code42cli` helper * adjust * add PATH troubleshooting steps to gettingstarted.md * appease flake8 * use powershell markdown blocks * style * IX feedback --- docs/userguides/gettingstarted.md | 37 ++++++++++++++++++++++++++++++- src/code42cli/__main__.py | 3 +++ src/code42cli/main.py | 21 ++++++++++++++++-- 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 src/code42cli/__main__.py diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index 4669d54ad..77a2363fc 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -22,7 +22,7 @@ python3 -m pip install code42cli ``` To install a previous version of the Code42 CLI via `pip`, add the version number. For example, to install version -0.4.1, enter: +0.5.3, enter: ```bash python3 -m pip install code42cli==0.5.3 @@ -123,6 +123,41 @@ To learn more about authenticating in the CLI, follow the [Configure profile gui ## Troubleshooting and support +### Code42 command not found + +If your python installation has added itself to your environment's PATH variable, then running `code42` _should_ just work. + +However, if after installation the `code42` command is not found, the CLI has some helpers for this (added in version 1.10): + +You can execute the CLI by calling the python module directly: + +```bash +python3 -m code42cli +``` + +And the base `code42` command now has a `--script-dir` option that will print out the directory the `code42` script was +installed into, so you can manually add it to your PATH, enabling the `code42` command to work. + +#### On Mac/Linux: + +Run the following to make `code42` visible in your shell's PATH (to persist the change, add it to your shell's configuration file): + +```bash +export PATH=$PATH:$(python3 -m code42cli --script-dir) +``` + +#### On Windows: + +```powershell +$env:Path += ";$(python -m code42cli --script-dir)" +``` + +To persist the change, add the updated PATH to your registry: + +```powershell +Set-ItemProperty -Path 'Registry::HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager\Environment' -Name PATH -Value $env:Path +``` + ### Debug mode Debug mode may be useful if you are trying to determine if you are experiencing permissions issues. When debug mode is diff --git a/src/code42cli/__main__.py b/src/code42cli/__main__.py new file mode 100644 index 000000000..9dc21ac74 --- /dev/null +++ b/src/code42cli/__main__.py @@ -0,0 +1,3 @@ +from code42cli.main import cli + +cli(prog_name="code42") diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 0dcf04197..e957da71c 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -1,4 +1,6 @@ +import os import signal +import site import sys import click @@ -53,13 +55,28 @@ def exit_on_interrupt(signal, frame): @click.option( "--python", is_flag=True, - help="Print path to the python interpreter env that `code42` is installed in.", + help="Print path to the python interpreter env that `code42cli` is installed in.", +) +@click.option( + "--script-dir", + is_flag=True, + help="Print the directory the `code42` script was installed in (for adding to your PATH if needed).", ) @sdk_options(hidden=True) -def cli(state, python): +def cli(state, python, script_dir): if python: click.echo(sys.executable) sys.exit(0) + if script_dir: + for root, _dirs, files in os.walk(site.PREFIXES[0]): + if "code42" in files or "code42.exe" in files: + print(root) + sys.exit(0) + + for root, _dirs, files in os.walk(site.USER_BASE): + if "code42" in files or "code42.exe" in files: + print(root) + sys.exit(0) cli.add_command(alerts) From a3e7f78e2f88a7e5c03301f768e9db0ba1caecad Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 27 Sep 2021 08:29:08 -0500 Subject: [PATCH 275/349] note required nature of --begin (#324) * note required nature of --begin * passed > used --- src/code42cli/options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code42cli/options.py b/src/code42cli/options.py index bf4f1a4fe..efa33331b 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -165,7 +165,7 @@ def server_options(f): def begin_option(term, **kwargs): defaults = dict( type=MagicDate(rounding_func=round_datetime_to_day_start), - help=f"The beginning of the date range in which to look for {term}. {MagicDate.HELP_TEXT}", + help=f"The beginning of the date range in which to look for {term}. {MagicDate.HELP_TEXT} [required unless --use-checkpoint option used]", cls=BeginOption, callback=lambda ctx, param, arg: convert_datetime_to_timestamp(arg), ) From 2498ee4d548e9bfcf1f6dd1971baee7ed4f5bb06 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Wed, 29 Sep 2021 09:05:58 -0500 Subject: [PATCH 276/349] adding trusted activities commands (#325) * adding trusted activities commands * fixing integration test * added trust service to build.yml * doc fixes * styling * remove trusted-activities show cmd * remove trusted-activities show integration test * styling * adjust changelog * by row error handling * check resource-id type in bulk cmds * style * whitespace * removing redundant try-catch block --- .github/workflows/build.yml | 1 + CHANGELOG.md | 17 +- docs/commands.md | 11 +- docs/commands/trustedactivities.rst | 3 + setup.py | 2 +- src/code42cli/click_ext/groups.py | 6 + src/code42cli/cmds/trustedactivities.py | 215 +++++++++++ src/code42cli/main.py | 2 + tests/cmds/test_cases.py | 2 +- tests/cmds/test_trustedactivities.py | 382 ++++++++++++++++++++ tests/integration/test_trustedactivities.py | 11 + 11 files changed, 641 insertions(+), 11 deletions(-) create mode 100644 docs/commands/trustedactivities.rst create mode 100644 src/code42cli/cmds/trustedactivities.py create mode 100644 tests/cmds/test_trustedactivities.py create mode 100644 tests/integration/test_trustedactivities.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 72e1f604f..0d381040b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,6 +50,7 @@ jobs: 127.0.0.1 preservation-data-service 127.0.0.1 connected-server 127.0.0.1 cases + 127.0.0.1 trusted-activities-service EOF - name: Install ncat run: sudo apt-get install ncat diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d312339e..94bed12b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,13 +15,22 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - New option `--include-legal-hold-membership` on command `code42 users list` that includes the legal hold matter name and ID for any user on legal hold. - New commands for deactivating/reactivating Code42 user accounts: - - `code42 users deactivate` - - `code42 users reactivate` - - `code42 users bulk deactivate` - - `code42 users bulk reactivate` + - `code42 users deactivate` + - `code42 users reactivate` + - `code42 users bulk deactivate` + - `code42 users bulk reactivate` - `code42 profile use` now prompts you to select a profile when not given a profile name argument. +- New `trusted-activities` commands for managing trusted activities and resources: + - `code42 trusted-activities create` to create a trusted activity. + - `code42 trusted-activities update` to update a trusted activity. + - `code42 trusted-activities remove` to remove a trusted activity. + - `code42 trusted-activities list` to print the details of all trusted activities. + - `code42 trusted-activities bulk create` to bulk create trusted activities from a CSV file. + - `code42 trusted-activities bulk update` to bulk update trusted activities from a CSV file. + - `code42 trusted-activities bulk remove` to bulk remove trusted activities from a CSV file. + ### Fixed - Bug where `audit-logs search` with `--use-checkpoint` option was causing output formatting problems. diff --git a/docs/commands.md b/docs/commands.md index f523fb790..c697e87d9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,13 +1,14 @@ # Commands -* [Profile](commands/profile.rst) -* [Security Data](commands/securitydata.rst) -* [Audit Logs](commands/auditlogs.rst) -* [Alerts](commands/alerts.rst) * [Alert Rules](commands/alertrules.rst) +* [Alerts](commands/alerts.rst) +* [Audit Logs](commands/auditlogs.rst) +* [Cases](commands/cases.rst) * [Departing Employee](commands/departingemployee.rst) * [Devices](commands/devices.rst) * [High Risk Employee](commands/highriskemployee.rst) * [Legal Hold](commands/legalhold.rst) -* [Cases](commands/cases.rst) +* [Profile](commands/profile.rst) +* [Security Data](commands/securitydata.rst) +* [Trusted Activities](commands/trustedactivities.rst) * [Users](commands/users.rst) diff --git a/docs/commands/trustedactivities.rst b/docs/commands/trustedactivities.rst new file mode 100644 index 000000000..67a114086 --- /dev/null +++ b/docs/commands/trustedactivities.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.trustedactivities:trusted_activities + :prog: trusted-activities + :nested: full diff --git a/setup.py b/setup.py index 33810564c..629e5ce28 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ "keyrings.alt==3.2.0", "ipython>=7.16.1", "pandas>=1.1.3", - "py42>=1.18.1", + "py42>=1.19.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index f1912982e..83ebcf01b 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -15,6 +15,9 @@ from py42.exceptions import Py42InvalidUsernameError from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError from py42.exceptions import Py42OrgNotFoundError +from py42.exceptions import Py42TrustedActivityConflictError +from py42.exceptions import Py42TrustedActivityIdNotFound +from py42.exceptions import Py42TrustedActivityInvalidCharacterError from py42.exceptions import Py42UpdateClosedCaseError from py42.exceptions import Py42UserAlreadyAddedError from py42.exceptions import Py42UsernameMustBeEmailError @@ -79,6 +82,9 @@ def invoke(self, ctx): Py42InvalidUsernameError, Py42ActiveLegalHoldError, Py42OrgNotFoundError, + Py42TrustedActivityConflictError, + Py42TrustedActivityInvalidCharacterError, + Py42TrustedActivityIdNotFound, ) as err: self.logger.log_error(err) raise Code42CLIError(str(err)) diff --git a/src/code42cli/cmds/trustedactivities.py b/src/code42cli/cmds/trustedactivities.py new file mode 100644 index 000000000..7c667da49 --- /dev/null +++ b/src/code42cli/cmds/trustedactivities.py @@ -0,0 +1,215 @@ +import click +from py42.clients.trustedactivities import TrustedActivityType + +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process +from code42cli.click_ext.groups import OrderedGroup +from code42cli.errors import Code42CLIError +from code42cli.file_readers import read_csv_arg +from code42cli.options import format_option +from code42cli.options import sdk_options +from code42cli.output_formats import OutputFormatter + +resource_id_arg = click.argument("resource-id", type=int) +type_option = click.option( + "--type", + help=f"Type of trusted activity. Valid types include {', '.join(TrustedActivityType.choices())}.", + type=click.Choice(TrustedActivityType.choices()), +) +value_option = click.option( + "--value", + help="The value of the trusted activity, such as the domain or Slack workspace name.", +) +description_option = click.option( + "--description", help="The description of the trusted activity." +) + + +def _get_trust_header(): + return { + "resourceId": "Resource Id", + "type": "Type", + "value": "Value", + "description": "Description", + "updatedAt": "Last Update Time", + "updatedByUsername": "Last Updated By (Username)", + "updatedByUserUid": "Last updated By (UserUID)", + } + + +@click.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def trusted_activities(state): + """Manage trusted activities and resources.""" + pass + + +@trusted_activities.command() +@click.argument("type", type=click.Choice(TrustedActivityType.choices())) +@click.argument("value") +@description_option +@sdk_options() +def create(state, type, value, description): + """Create a trusted activity. + + VALUE is the name of the domain or Slack workspace. + """ + state.sdk.trustedactivities.create( + type, value, description=description, + ) + + +@trusted_activities.command() +@resource_id_arg +@value_option +@description_option +@sdk_options() +def update(state, resource_id, value, description): + """Update a trusted activity. Requires the activity's resource ID.""" + state.sdk.trustedactivities.update( + resource_id, value=value, description=description, + ) + + +@trusted_activities.command() +@resource_id_arg +@sdk_options() +def remove(state, resource_id): + """Remove a trusted activity. Requires the activity's resource ID.""" + state.sdk.trustedactivities.delete(resource_id) + + +@trusted_activities.command("list") +@click.option("--type", type=click.Choice(TrustedActivityType.choices())) +@format_option +@sdk_options() +def _list(state, type, format): + """List all trusted activities.""" + pages = state.sdk.trustedactivities.get_all(type=type) + formatter = OutputFormatter(format, _get_trust_header()) + trusted_resources = [ + resource for page in pages for resource in page["trustResources"] + ] + if trusted_resources: + formatter.echo_formatted_list(trusted_resources) + else: + click.echo("No trusted activities found.") + + +@trusted_activities.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def bulk(state): + """Tools for executing bulk trusted activity actions.""" + pass + + +TRUST_CREATE_HEADERS = [ + "type", + "value", + "description", +] +TRUST_UPDATE_HEADERS = [ + "resource_id", + "value", + "description", +] +TRUST_REMOVE_HEADERS = [ + "resource_id", +] + +trusted_activities_generate_template = generate_template_cmd_factory( + group_name="trusted_activities", + commands_dict={ + "create": TRUST_CREATE_HEADERS, + "update": TRUST_UPDATE_HEADERS, + "remove": TRUST_REMOVE_HEADERS, + }, + help_message="Generate the CSV template needed for bulk trusted-activities commands", +) +bulk.add_command(trusted_activities_generate_template) + + +@bulk.command( + name="create", + help="Bulk create trusted activities using a CSV file with " + f"format: {','.join(TRUST_UPDATE_HEADERS)}.", +) +@read_csv_arg(headers=TRUST_CREATE_HEADERS) +@sdk_options() +def bulk_create(state, csv_rows): + """Bulk create trusted activities.""" + sdk = state.sdk + + def handle_row(type, value, description): + if type not in TrustedActivityType.choices(): + message = f"Invalid type {type}, valid types include {', '.join(TrustedActivityType.choices())}." + raise Code42CLIError(message) + if type is None: + message = "'type' is a required field to create a trusted activity." + raise Code42CLIError(message) + if value is None: + message = "'value' is a required field to create a trusted activity." + raise Code42CLIError(message) + sdk.trustedactivities.create(type, value, description) + + run_bulk_process( + handle_row, csv_rows, progress_label="Creating trusting activities:", + ) + + +@bulk.command( + name="update", + help="Bulk update trusted activities using a CSV file with " + f"format: {','.join(TRUST_UPDATE_HEADERS)}.", +) +@read_csv_arg(headers=TRUST_UPDATE_HEADERS) +@sdk_options() +def bulk_update(state, csv_rows): + """Bulk update trusted activities.""" + sdk = state.sdk + + def handle_row(resource_id, value, description): + if resource_id is None: + message = "'resource_id' is a required field to update a trusted activity." + raise Code42CLIError(message) + _check_resource_id_type(resource_id) + sdk.trustedactivities.update(resource_id, value, description) + + run_bulk_process( + handle_row, csv_rows, progress_label="Updating trusted activities:" + ) + + +@bulk.command( + name="remove", + help="Bulk remove trusted activities using a CSV file with " + f"format: {','.join(TRUST_REMOVE_HEADERS)}.", +) +@read_csv_arg(headers=TRUST_REMOVE_HEADERS) +@sdk_options() +def bulk_remove(state, csv_rows): + """Bulk remove trusted activities.""" + sdk = state.sdk + + def handle_row(resource_id): + if resource_id is None: + message = "'resource_id' is a required field to remove a trusted activity." + raise Code42CLIError(message) + _check_resource_id_type(resource_id) + sdk.trustedactivities.delete(resource_id) + + run_bulk_process( + handle_row, csv_rows, progress_label="Removing trusted activities:", + ) + + +def _check_resource_id_type(resource_id): + def raise_error(resource_id): + message = f"Invalid resource ID {resource_id}. Must be an integer." + raise Code42CLIError(message) + + try: + if not float(resource_id).is_integer(): + raise_error(resource_id) + except ValueError: + raise_error(resource_id) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index e957da71c..897b96a0a 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -22,6 +22,7 @@ from code42cli.cmds.profile import profile from code42cli.cmds.securitydata import security_data from code42cli.cmds.shell import shell +from code42cli.cmds.trustedactivities import trusted_activities from code42cli.cmds.users import users from code42cli.options import sdk_options @@ -91,3 +92,4 @@ def cli(state, python, script_dir): cli.add_command(security_data) cli.add_command(shell) cli.add_command(users) +cli.add_command(trusted_activities) diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index ee646ac8d..ec3557bc9 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -417,7 +417,7 @@ def test_cases_create_when_description_length_limit_exceeds_raises_exception_pri assert "Description limit exceeded, max 250 characters allowed." in result.output -def test_cases_udpate_when_description_length_limit_exceeds_raises_exception_prints_error_message( +def test_cases_update_when_description_length_limit_exceeds_raises_exception_prints_error_message( runner, cli_state, case_description_limit_exceeded_error ): cli_state.sdk.cases.update.side_effect = case_description_limit_exceeded_error diff --git a/tests/cmds/test_trustedactivities.py b/tests/cmds/test_trustedactivities.py new file mode 100644 index 000000000..31ff9cab6 --- /dev/null +++ b/tests/cmds/test_trustedactivities.py @@ -0,0 +1,382 @@ +import pytest +from py42.exceptions import Py42DescriptionLimitExceededError +from py42.exceptions import Py42TrustedActivityConflictError +from py42.exceptions import Py42TrustedActivityIdNotFound +from py42.exceptions import Py42TrustedActivityInvalidCharacterError +from tests.conftest import create_mock_response + +from code42cli.main import cli + +TEST_RESOURCE_ID = 123 +ALL_TRUSTED_ACTIVITIES = """ +{ + "trustResources": [ + { + "description": "test description", + "resourceId": 456, + "type": "DOMAIN", + "updatedAt": "2021-09-22T15:46:35.088Z", + "updatedByUserUid": "user123", + "updatedByUsername": "username", + "value": "test" + } + ], + "totalCount": 10 +} +""" + +TRUSTED_ACTIVITY_DETAILS = """ +{ + "description": "test description", + "resourceId": 123, + "type": "DOMAIN", + "updatedAt": "2021-09-22T20:39:59.999Z", + "updatedByUserUid": "user123", + "updatedByUsername": "username", + "value": "test" +} +""" + +MISSING_ARGUMENT_ERROR = "Missing argument '{}'." +MISSING_TYPE = MISSING_ARGUMENT_ERROR.format("[DOMAIN|SLACK]") +MISSING_VALUE = MISSING_ARGUMENT_ERROR.format("VALUE") +MISSING_RESOURCE_ID_ARG = MISSING_ARGUMENT_ERROR.format("RESOURCE_ID") +RESOURCE_ID_NOT_FOUND_ERROR = "Resource ID '{}' not found." +INVALID_CHARACTER_ERROR = "Invalid character in domain or slack workspace name" +CONFLICT_ERROR = ( + "Duplicate URL or workspace name, '{}' already exists on your trusted list." +) +DESCRIPTION_LIMIT_ERROR = "Description limit exceeded, max 250 characters allowed." + + +@pytest.fixture +def get_all_activities_response(mocker): + def gen(): + yield create_mock_response(mocker, data=ALL_TRUSTED_ACTIVITIES) + + return gen() + + +@pytest.fixture +def trusted_activity_conflict_error(custom_error): + return Py42TrustedActivityConflictError(custom_error, "test-case") + + +@pytest.fixture +def trusted_activity_description_limit_exceeded_error(custom_error): + return Py42DescriptionLimitExceededError(custom_error) + + +@pytest.fixture +def trusted_activity_invalid_character_error(custom_error): + return Py42TrustedActivityInvalidCharacterError(custom_error) + + +@pytest.fixture +def trusted_activity_resource_id_not_found_error(custom_error): + return Py42TrustedActivityIdNotFound(custom_error, TEST_RESOURCE_ID) + + +def test_create_calls_create_with_expected_params(runner, cli_state): + command = ["trusted-activities", "create", "DOMAIN", "test-activity"] + runner.invoke( + cli, command, obj=cli_state, + ) + cli_state.sdk.trustedactivities.create.assert_called_once_with( + "DOMAIN", "test-activity", description=None + ) + + +def test_create_with_optional_fields_calls_create_with_expected_params( + runner, cli_state +): + command = [ + "trusted-activities", + "create", + "SLACK", + "test-activity", + "--description", + "description", + ] + runner.invoke( + cli, command, obj=cli_state, + ) + cli_state.sdk.trustedactivities.create.assert_called_once_with( + "SLACK", "test-activity", description="description" + ) + + +def test_create_when_missing_type_prints_error(runner, cli_state): + command = ["trusted-activities", "create", "--description", "description"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_TYPE in result.output + + +def test_create_when_missing_value_prints_error(runner, cli_state): + command = ["trusted-activities", "create", "DOMAIN", "--description", "description"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_VALUE in result.output + + +def test_create_when_invalid_character_py42_raises_exception_prints_error( + runner, cli_state, trusted_activity_invalid_character_error +): + cli_state.sdk.trustedactivities.create.side_effect = ( + trusted_activity_invalid_character_error + ) + command = ["trusted-activities", "create", "DOMAIN", "inv@lid-domain"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert INVALID_CHARACTER_ERROR in result.output + + +def test_create_when_duplicate_value_conflict_py42_raises_exception_prints_error( + runner, cli_state, trusted_activity_conflict_error +): + cli_state.sdk.trustedactivities.create.side_effect = trusted_activity_conflict_error + command = ["trusted-activities", "create", "DOMAIN", "test-case"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert CONFLICT_ERROR.format("test-case") in result.output + + +def test_create_when_description_limit_exceeded_py42_raises_exception_prints_error( + runner, cli_state, trusted_activity_description_limit_exceeded_error +): + cli_state.sdk.trustedactivities.create.side_effect = ( + trusted_activity_description_limit_exceeded_error + ) + command = [ + "trusted-activities", + "create", + "DOMAIN", + "test-domain", + "--description", + ">250 characters", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert DESCRIPTION_LIMIT_ERROR in result.output + + +def test_update_calls_update_with_expected_params(runner, cli_state): + command = [ + "trusted-activities", + "update", + f"{TEST_RESOURCE_ID}", + "--value", + "test-activity-update", + ] + runner.invoke( + cli, command, obj=cli_state, + ) + cli_state.sdk.trustedactivities.update.assert_called_once_with( + TEST_RESOURCE_ID, value="test-activity-update", description=None, + ) + + +def test_update_with_optional_fields_calls_update_with_expected_params( + runner, cli_state +): + command = [ + "trusted-activities", + "update", + f"{TEST_RESOURCE_ID}", + "--value", + "test-activity-update", + "--description", + "update description", + ] + runner.invoke( + cli, command, obj=cli_state, + ) + cli_state.sdk.trustedactivities.update.assert_called_once_with( + TEST_RESOURCE_ID, + value="test-activity-update", + description="update description", + ) + + +def test_update_when_missing_resource_id_prints_error(runner, cli_state): + command = ["trusted-activities", "update", "--value", "test-activity-update"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_RESOURCE_ID_ARG in result.output + + +def test_update_when_resource_id_not_found_py42_raises_exception_prints_error( + runner, cli_state, trusted_activity_resource_id_not_found_error +): + cli_state.sdk.trustedactivities.update.side_effect = ( + trusted_activity_resource_id_not_found_error + ) + command = ["trusted-activities", "update", f"{TEST_RESOURCE_ID}"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert RESOURCE_ID_NOT_FOUND_ERROR.format(TEST_RESOURCE_ID) in result.output + + +def test_update_when_invalid_character_py42_raises_exception_prints_error( + runner, cli_state, trusted_activity_invalid_character_error +): + cli_state.sdk.trustedactivities.update.side_effect = ( + trusted_activity_invalid_character_error + ) + command = [ + "trusted-activities", + "update", + f"{TEST_RESOURCE_ID}", + "--value", + "inv@lid-domain", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert INVALID_CHARACTER_ERROR in result.output + + +def test_update_when_duplicate_value_conflict_py42_raises_exception_prints_error( + runner, cli_state, trusted_activity_conflict_error +): + cli_state.sdk.trustedactivities.update.side_effect = trusted_activity_conflict_error + command = [ + "trusted-activities", + "update", + f"{TEST_RESOURCE_ID}", + "--value", + "test-case", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert CONFLICT_ERROR.format("test-case") in result.output + + +def test_update_when_description_limit_exceeded_py42_raises_exception_prints_error( + runner, cli_state, trusted_activity_description_limit_exceeded_error +): + cli_state.sdk.trustedactivities.update.side_effect = ( + trusted_activity_description_limit_exceeded_error + ) + command = [ + "trusted-activities", + "update", + f"{TEST_RESOURCE_ID}", + "--description", + ">250 characters", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert DESCRIPTION_LIMIT_ERROR in result.output + + +def test_remove_calls_delete_with_expected_params(runner, cli_state): + command = ["trusted-activities", "remove", f"{TEST_RESOURCE_ID}"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.trustedactivities.delete.assert_called_once_with(TEST_RESOURCE_ID) + + +def test_remove_when_missing_resource_id_prints_error(runner, cli_state): + command = ["trusted-activities", "remove"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 2 + assert MISSING_RESOURCE_ID_ARG in result.output + + +def test_remove_when_resource_id_not_found_py42_raises_exception_prints_error( + runner, cli_state, trusted_activity_resource_id_not_found_error +): + cli_state.sdk.trustedactivities.delete.side_effect = ( + trusted_activity_resource_id_not_found_error + ) + command = ["trusted-activities", "remove", f"{TEST_RESOURCE_ID}"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert RESOURCE_ID_NOT_FOUND_ERROR.format(TEST_RESOURCE_ID) in result.output + + +def test_list_calls_get_all_with_expected_params(runner, cli_state): + command = ["trusted-activities", "list"] + runner.invoke(cli, command, obj=cli_state) + assert cli_state.sdk.trustedactivities.get_all.call_count == 1 + + +def test_list_with_optional_fields_called_get_all_with_expected_params( + runner, cli_state +): + command = ["trusted-activities", "list", "--type", "DOMAIN"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.trustedactivities.get_all.assert_called_once_with(type="DOMAIN") + + +def test_list_prints_expected_data(runner, cli_state, get_all_activities_response): + cli_state.sdk.trustedactivities.get_all.return_value = get_all_activities_response + command = ["trusted-activities", "list"] + result = runner.invoke(cli, command, obj=cli_state) + assert "2021-09-22T15:46:35.088Z" in result.output + assert "456" in result.output + + +def test_bulk_add_trusted_activities_uses_expected_arguments( + runner, mocker, cli_state_with_user +): + bulk_processor = mocker.patch("code42cli.cmds.trustedactivities.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_create.csv", "w") as csv: + csv.writelines( + [ + "type,value,description\n", + "DOMAIN,test-domain,\n", + "SLACK,test-slack,desc\n", + ] + ) + command = ["trusted-activities", "bulk", "create", "test_create.csv"] + runner.invoke( + cli, command, obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"type": "DOMAIN", "value": "test-domain", "description": ""}, + {"type": "SLACK", "value": "test-slack", "description": "desc"}, + ] + + +def test_bulk_update_trusted_activities_uses_expected_arguments( + runner, mocker, cli_state_with_user +): + bulk_processor = mocker.patch("code42cli.cmds.trustedactivities.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_update.csv", "w") as csv: + csv.writelines( + [ + "resource_id,value,description\n", + "1,test-domain,\n", + "2,test-slack,desc\n", + "3,,desc\n", + ] + ) + command = ["trusted-activities", "bulk", "update", "test_update.csv"] + runner.invoke( + cli, command, obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"resource_id": "1", "value": "test-domain", "description": ""}, + {"resource_id": "2", "value": "test-slack", "description": "desc"}, + {"resource_id": "3", "value": "", "description": "desc"}, + ] + + +def test_bulk_remove_trusted_activities_uses_expected_arguments( + runner, mocker, cli_state_with_user +): + bulk_processor = mocker.patch("code42cli.cmds.trustedactivities.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines(["resource_id\n", "1\n", "2\n"]) + command = ["trusted-activities", "bulk", "remove", "test_remove.csv"] + runner.invoke( + cli, command, obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"resource_id": "1"}, + {"resource_id": "2"}, + ] diff --git a/tests/integration/test_trustedactivities.py b/tests/integration/test_trustedactivities.py new file mode 100644 index 000000000..d8d99cc1c --- /dev/null +++ b/tests/integration/test_trustedactivities.py @@ -0,0 +1,11 @@ +import pytest +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful + + +@pytest.mark.integration +def test_trusted_activities_list_command_returns_success_return_code( + runner, integration_test_profile +): + command = "trusted-activities list" + assert_test_is_successful(runner, append_profile(command)) From 3427396fed09fb842bdefc5b80d5581f02e17e18 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 5 Oct 2021 10:45:54 -0500 Subject: [PATCH 277/349] version bump, changelog update (#327) --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bed12b9..c6e566430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.10.0 - 2021-10-05 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 0a0a43a57..fcfdf3836 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.9.0" +__version__ = "1.10.0" From 7258218f6c1680dd8354e1df662da503d62dfe73 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Fri, 8 Oct 2021 12:22:58 -0500 Subject: [PATCH 278/349] Chore/add user guides (#326) * trust user guide * adding trust user guides * user guides * adding user guides * user guides * user guides * PR feedback * PR feedback * PR feedback * Update trustedactivities.md Updates to phrasing for brevity, clarity and style. The "Note" section within "Update a Trusted Activity" appeared to be entirely in a code block, which didn't seem right. * Update users.md Updates to phrasing for clarity, brevity, and style. * style * format note block * tiny wording adjustments * /s Co-authored-by: annie-payseur <52421911+annie-payseur@users.noreply.github.com> --- docs/guides.md | 6 +- docs/userguides/alertrules.md | 110 +++++++++++++++++++++++++++ docs/userguides/cases.md | 96 +++++++++++++++++++++++ docs/userguides/deactivatedevices.md | 2 + docs/userguides/detectionlists.md | 5 +- docs/userguides/legalhold.md | 5 +- docs/userguides/siemexample.md | 104 ++++++++++++++++--------- docs/userguides/trustedactivities.md | 74 ++++++++++++++++++ docs/userguides/users.md | 66 ++++++++++++++++ 9 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 docs/userguides/alertrules.md create mode 100644 docs/userguides/cases.md create mode 100644 docs/userguides/trustedactivities.md create mode 100644 docs/userguides/users.md diff --git a/docs/guides.md b/docs/guides.md index 1000a178d..3bc4f3df8 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -2,8 +2,12 @@ * [Get started with the Code42 command-line interface (CLI)](userguides/gettingstarted.md) * [Configure a profile](userguides/profile.md) -* [Ingest file events or alerts into a SIEM](userguides/siemexample.md) +* [Ingest Data into a SIEM](userguides/siemexample.md) * [Manage detection list users](userguides/detectionlists.md) * [Manage legal hold users](userguides/legalhold.md) * [Clean up your environment by deactivating devices](userguides/deactivatedevices.md) * [Write custom extension scripts using the Code42 CLI and py42](userguides/extensions.md) +* [Manage Users](userguides/users.md) +* [Configure Trusted Activities](userguides/trustedactivities.md) +* [Configure Alert Rules](userguides/alertrules.md) +* [Add and Manage Cases](userguides/cases.md) diff --git a/docs/userguides/alertrules.md b/docs/userguides/alertrules.md new file mode 100644 index 000000000..bc6462627 --- /dev/null +++ b/docs/userguides/alertrules.md @@ -0,0 +1,110 @@ +# Add Users to Alert Rules + +Once you [create an alert rule in the Code42 console](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Alert_rule_settings_reference), you can use the CLI `alert-rules` commands to add and remove users from your existing alert rules. + +To see a list of all the users currently in your organization: +- Export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Users_reference#Action_menu). +- Use the [CLI users commands](./users.md). + +## View Existing Alert Rules + +You'll need the ID of an alert rule to add or remove a user. + +To view a list of all alert rules currently created for your organization, including the rule ID, use the following command: +```bash +code42 alert-rules list +``` + +Once you've identified the rule ID, view the details of the alert rule as follows: +```bash +code42 alert-rules show +``` + +#### Example output +Example output for a single alert rule in default JSON format. +```json +{ + "type$": "ENDPOINT_EXFILTRATION_RULE_DETAILS_RESPONSE", + "rules": [ + { + "type$": "ENDPOINT_EXFILTRATION_RULE_DETAILS", + "tenantId": "c4e43418-07d9-4a9f-a138-29f39a124d33", + "name": "My Rule", + "description": "this is your rule!", + "severity": "HIGH", + "isEnabled": false, + "fileBelongsTo": { + "type$": "FILE_BELONGS_TO", + "usersToAlertOn": "ALL_USERS" + }, + "notificationConfig": { + "type$": "NOTIFICATION_CONFIG", + "enabled": false + }, + "fileCategoryWatch": { + "type$": "FILE_CATEGORY_WATCH", + "watchAllFiles": true + }, + "ruleSource": "Alerting", + "fileSizeAndCount": { + "type$": "FILE_SIZE_AND_COUNT", + "fileCountGreaterThan": 2, + "totalSizeGreaterThanInBytes": 200, + "operator": "AND" + }, + "fileActivityIs": { + "type$": "FILE_ACTIVITY", + "syncedToCloudService": { + "type$": "SYNCED_TO_CLOUD_SERVICE", + "watchBox": false, + "watchBoxDrive": false, + "watchDropBox": false, + "watchGoogleBackupAndSync": false, + "watchAppleIcLoud": false, + "watchMicrosoftOneDrive": false + }, + "uploadedOnRemovableMedia": true, + "readByBrowserOrOther": true + }, + "timeWindow": 15, + "id": "404ff012-fa2f-4acf-ae6d-107eabf7f24c", + "createdAt": "2021-04-27T01:55:36.4204590Z", + "createdBy": "sean.cassidy@example.com", + "modifiedAt": "2021-09-03T01:46:13.2902310Z", + "modifiedBy": "sean.cassidy@example.com", + "isSystem": false + } + ] +} +``` + +## Add a User to an Alert Rule + +You can manage the users who are associated with an alert rule once you know the rule's `rule_id` and the user's `username`. + +To add a single user to your alert rule, use the following command: +```bash +code42 alert-rules add-user --rule-id -u sean.cassidy@example.com +``` + +Alternatively, to add multiple users to your alert rule, fill out the `add` CSV file template, then use the `bulk add` command with the CSV file path. +```bash +code42 alert-rules bulk add users.csv +``` + +You can remove single or multiple users from alert rules similarly using the `remove-user` and `bulk remove` commands. + + +## Get CSV Template + +The following command will generate a CSV template to either add or remove users from multiple alert rules at once. The CSV file will be saved to the current working directory. +```bash +code42 alert-rules bulk generate-template [add|remove] +``` + +You can then fill out and use each of the CSV templates with their respective bulk commands. +```bash +code42 alert-rules bulk [add|remove] /Users/my_user/bulk-command.csv +``` + +Learn more about the [Alert Rules](../commands/alertrules.md) commands. diff --git a/docs/userguides/cases.md b/docs/userguides/cases.md new file mode 100644 index 000000000..06f72e057 --- /dev/null +++ b/docs/userguides/cases.md @@ -0,0 +1,96 @@ +# Add and Manage Cases + +To create a new case, only the name is required. Other attributes are optional and can be provided through the available flags. + +The following command creates a case with the `subject` and `assignee` user indicated by their respective UIDs. +```bash +code42 cases create My-Case --subject 123 --assignee 456 --description "Sample case" +``` + +## Update a Case + +To further update or view the details of your case, you'll need the case's unique number, which is assigned upon creation. To get this number, you can use the `list` command to view all cases, with optional filter values. + +To print to the console all open cases created in the last 30 days: +```bash +code42 cases list --begin-create-time 30d --status OPEN +``` + +#### Example Output +Example output for a single case in JSON format. +```json +{ + "number": 42, + "name": "My-Case", + "createdAt": "2021-9-17T18:29:53.375136Z", + "updatedAt": "2021-9-17T18:29:53.375136Z", + "description": "Sample case", + "findings": "", + "subject": "123", + "subjectUsername": "sean.cassidy@example.com", + "status": "OPEN", + "assignee": "456", + "assigneeUsername": "elvis.presley@example.com", + "createdByUserUid": "789", + "createdByUsername": "andy.warhol@example.com", + "lastModifiedByUserUid": "789", + "lastModifiedByUsername": "andy.warhol@example.com" +} +``` + +Once you've identified your case's number, you can view further details on the case, or update its attributes. + +The following command will print all details of your case. +```bash +code42 cases show 42 +``` + +If you've finished your investigation and you'd like to close your case, you can update the status of the case. Similarly, other attributes of the case can be updated using the optional flags. +```bash +code42 cases update 42 --status CLOSED +``` + +## Get CSV Template + +The following command will generate a CSV template to either add or remove file events from multiple cases at once. The csv file will be saved to the current working directory. +```bash +code42 cases file-events bulk generate-template [add|remove] +``` + +You can then fill out and use each of the CSV templates with their respective bulk commands. +```bash +code42 cases file-events bulk [add|remove] bulk-command.csv +``` + +## Manage File Exposure Events Associated with a Case + +The following example command can be used to view all the file exposure events currently associated with a case, indicated here by case number `42`. +```bash +code42 cases file-events list 42 +``` + +Use the `file-events add` command to associate a single file event, referred to by event ID, to a case. + +Below is an example command to associate some event with ID `event_abc` with case number `42`. +```bash +code42 cases file-events add 42 event_abc +``` + +To associate multiple file events with one or more cases at once, enter the case and file event information into the `file-events add` CSV file template, then use the `bulk add` command with the CSV file path. For example: +```bash +code42 cases file-events bulk add my_new_cases.csv +``` + +Similarly, the `file-events remove` and `file-events bulk remove` commands can be used to remove a file event from a case. + +## Export Case Details + +You can use the CLI to export the details of a case into a PDF. + +The following example command will download the details from case number `42` and save a PDF with the name `42_case_summary.pdf` to the provided path. If a path is not provided, it will be saved to the current working directory. + +```bash +code42 cases export 42 --path /Users/my_user/cases/ +``` + +Learn more about the [Managing Cases](../commands/cases.md). diff --git a/docs/userguides/deactivatedevices.md b/docs/userguides/deactivatedevices.md index 183bb7233..994df083b 100644 --- a/docs/userguides/deactivatedevices.md +++ b/docs/userguides/deactivatedevices.md @@ -95,3 +95,5 @@ code42 devices list --active \ This lists all devices that have not connected within a year _and_ are not a user's most-recently-connected device, and then attempts to deactivate them. + +Learn more about [Managing Devices](../commands/devices.md). diff --git a/docs/userguides/detectionlists.md b/docs/userguides/detectionlists.md index 0dbec2056..cabc2b1f8 100644 --- a/docs/userguides/detectionlists.md +++ b/docs/userguides/detectionlists.md @@ -2,8 +2,9 @@ Use the `departing-employee` commands to add employees to or remove employees from the Departing Employees list. Use the `high-risk-employee` commands to add employees to or remove employees from the High Risk list, or update risk tags for those users. -To see a list of all the users currently in your organization, you can export a list from the -[Users action menu](https://support.code42.com/Administrator/Cloud/Administration_console_reference/Users_reference#Action_menu). +To see a list of all the users currently in your organization: +- Export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Users_reference#Action_menu). +- Use the [CLI users commands](./users.md). ## Get CSV template To add multiple users to the Departing Employees list: diff --git a/docs/userguides/legalhold.md b/docs/userguides/legalhold.md index bfc717860..f01f46b71 100644 --- a/docs/userguides/legalhold.md +++ b/docs/userguides/legalhold.md @@ -2,8 +2,11 @@ Once you [create a legal hold matter in the Code42 console](https://support.code42.com/Administrator/Cloud/Configuring/Create_a_legal_hold_matter#Step_1:_Create_a_matter), you can use the Code42 CLI to add or release custodians from the matter. +To see a list of all the users currently in your organization: +- Export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Users_reference#Action_menu). +- Use the [CLI users commands](./users.md). + Use the `legal-hold` commands to manage legal hold custodians. - - To see a list of all the users currently in your organization, you can export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Users_reference#Action_menu). - To view a list of legal hold matters for your organization, including the matter ID, use the following command: `code42 legal-hold list` - To see a list of all the custodians currently associated with a legal hold matter, enter `code42 legal-hold show `. diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index d65575f23..4413dd21d 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -14,20 +14,30 @@ First install and configure the Code42 CLI following the instructions in [Getting Started](gettingstarted.md). ## Run queries -You can get file events in either a JSON or CEF format for use by your SIEM tool. Alerts data is available in JSON format. You can query the data as a -scheduled job or run ad-hoc queries. Learn more about [searching](../commands/securitydata.md) using the CLI. +You can get file events in either a JSON or CEF format for use by your SIEM tool. Alerts data and audit logs are available in JSON format. You can query the data as a +scheduled job or run ad-hoc queries. + +Learn more about searching [File Events](../commands/securitydata.md), [Alerts](../commands/alerts.md), and [Audit Logs](../commands/auditlogs.md) using the CLI. ### Run a query as a scheduled job Use your favorite scheduling tool, such as cron or Windows Task Scheduler, to run a query on a regular basis. Specify -the profile to use by including `--profile`. An example using the `send-to` command to forward only the new file event data since the previous request to an external syslog server: +the profile to use by including `--profile`. + +#### File Exposure Events +An example using the `send-to` command to forward only the new file event data since the previous request to an external syslog server: ```bash code42 security-data send-to syslog.example.com:514 -p UDP --profile profile1 -c syslog_sender ``` - +#### Alerts An example to send to the syslog server only the new alerts that meet the filter criteria since the previous request: ```bash -code42 alerts send-to syslog.example.com:514 -p UDP --profile profile1 --rule-name “Source code exfiltration” --state OPEN -i +code42 alerts send-to syslog.example.com:514 -p UDP --profile profile1 --rule-name "Source code exfiltration" --state OPEN -i +``` +#### Audit Logs +An example to send to the syslog server only the audit log events that meet the filter criteria from the last 30 days. +```bash +code42 audit-logs send-to syslog.example.com:514 -p UDP --profile profile1 --actor-username 'sean.cassidy@example.com' -b 30d ``` As a best practice, use a separate profile when executing a scheduled task. Using separate profiles can help prevent accidental updates to your stored checkpoints, for example, by adding `--use-checkpoint` to adhoc queries. @@ -36,6 +46,8 @@ As a best practice, use a separate profile when executing a scheduled task. Usin Examples of ad-hoc queries you can run are as follows. +#### File Exposure Events + Print file events since March 5 for a user in raw JSON format: ```bash code42 security-data search -f RAW-JSON -b 2020-03-05 --c42-username 'sean.cassidy@example.com' @@ -51,11 +63,18 @@ March 5: ```bash code42 security-data search -f RAW-JSON -b 2020-03-05 -t ApplicationRead --c42-username 'sean.cassidy@example.com' > /Users/sangita.maskey/Downloads/c42cli_output.txt ``` - +#### Alerts Print alerts since May 5 where a file's cloud share permissions changed: ```bash code42 alerts print -b 2020-05-05 --rule-type FedCloudSharePermissions ``` +#### Audit Logs +Print audit log events since June 5 which affected a certain user: +```bash +code42 audit-logs search -b 2021-06-05 --affected-username 'sean.cassidy@examply.com' +``` + +#### Example Outputs Example output for a single file exposure event (in default JSON format): @@ -97,36 +116,53 @@ Example output for a single file exposure event (in default JSON format): Example output for a single alert (in default JSON format): ```json -{"type$": "ALERT_DETAILS", -"tenantId": "c4b5e830-824a-40a3-a6d9-345664cfbb33", -"type": "FED_CLOUD_SHARE_PERMISSIONS", -"name": "Cloud Share", -"description": "Alert Rule for data exfiltration via Cloud Share", -"actor": "leland.stewart@example.com", -"target": "N/A", -"severity": "HIGH", -"ruleId": "408eb1ae-587e-421a-9444-f75d5399eacb", -"ruleSource": "Alerting", -"id": "7d936d0d-e783-4b24-817d-f19f625e0965", -"createdAt": "2020-05-22T09:47:33.8863230Z", -"state": "OPEN", -"observations": [{"type$": "OBSERVATION", -"id": "4bc378e6-bfbd-40f0-9572-6ed605ea9f6c", -"observedAt": "2020-05-22T09:40:00.0000000Z", -"type": "FedCloudSharePermissions", -"data": {"type$": "OBSERVED_CLOUD_SHARE_ACTIVITY", -"id": "4bc378e6-bfbd-40f0-9572-6ed605ea9f6c", -"sources": ["GoogleDrive"], -"exposureTypes": ["PublicLinkShare"], -"firstActivityAt": "2020-05-22T09:40:00.0000000Z", -"lastActivityAt": "2020-05-22T09:45:00.0000000Z", -"fileCount": 1, -"totalFileSize": 6025, -"fileCategories": [{"type$": "OBSERVED_FILE_CATEGORY", "category": "Document", "fileCount": 1, "totalFileSize": 6025, "isSignificant": false}], -"files": [{"type$": "OBSERVED_FILE", "eventId": "1hHdK6Qe6hez4vNCtS-UimDf-sbaFd-D7_3_baac33d0-a1d3-4e0a-9957-25632819eda7", "name": "1590140395_Longfellow_Cloud_Arch_Redesign.drawio", "category": "Document", "size": 6025}], -"outsideTrustedDomainsEmailsCount": 0, "outsideTrustedDomainsTotalDomainCount": 0, "outsideTrustedDomainsTotalDomainCountTruncated": false}}]} +{ + "type$": "ALERT_DETAILS", + "tenantId": "c4b5e830-824a-40a3-a6d9-345664cfbb33", + "type": "FED_CLOUD_SHARE_PERMISSIONS", + "name": "Cloud Share", + "description": "Alert Rule for data exfiltration via Cloud Share", + "actor": "leland.stewart@example.com", + "target": "N/A", + "severity": "HIGH", + "ruleId": "408eb1ae-587e-421a-9444-f75d5399eacb", + "ruleSource": "Alerting", + "id": "7d936d0d-e783-4b24-817d-f19f625e0965", + "createdAt": "2020-05-22T09:47:33.8863230Z", + "state": "OPEN", + "observations": [{"type$": "OBSERVATION", + "id": "4bc378e6-bfbd-40f0-9572-6ed605ea9f6c", + "observedAt": "2020-05-22T09:40:00.0000000Z", + "type": "FedCloudSharePermissions", + "data": { + "type$": "OBSERVED_CLOUD_SHARE_ACTIVITY", + "id": "4bc378e6-bfbd-40f0-9572-6ed605ea9f6c", + "sources": ["GoogleDrive"], + "exposureTypes": ["PublicLinkShare"], + "firstActivityAt": "2020-05-22T09:40:00.0000000Z", + "lastActivityAt": "2020-05-22T09:45:00.0000000Z", + "fileCount": 1, + "totalFileSize": 6025, + "fileCategories": [{"type$": "OBSERVED_FILE_CATEGORY", "category": "Document", "fileCount": 1, "totalFileSize": 6025, "isSignificant": false}], + "files": [{"type$": "OBSERVED_FILE", "eventId": "1hHdK6Qe6hez4vNCtS-UimDf-sbaFd-D7_3_baac33d0-a1d3-4e0a-9957-25632819eda7", "name": "1590140395_Longfellow_Cloud_Arch_Redesign.drawio", "category": "Document", "size": 6025}], + "outsideTrustedDomainsEmailsCount": 0, "outsideTrustedDomainsTotalDomainCount": 0, "outsideTrustedDomainsTotalDomainCountTruncated": false}}] +} ``` +Example output for a single audit log event (in default JSON format): +```json +{ + "type$": "audit_log::logged_in/1", + "actorId": "1015070955620029617", + "actorName": "sean.cassidy@example.com", + "actorAgent": "py42 1.17.0 python 3.7.10", + "actorIpAddress": "67.220.16.122", + "timestamp": "2021-08-30T16:16:19.165Z", + "actorType": "USER" +} +``` + + ## CEF Mapping The following tables map the file event data from the Code42 CLI to common event format (CEF). diff --git a/docs/userguides/trustedactivities.md b/docs/userguides/trustedactivities.md new file mode 100644 index 000000000..d41e6a5f4 --- /dev/null +++ b/docs/userguides/trustedactivities.md @@ -0,0 +1,74 @@ +# Configure Trusted Activities + +You can add trusted activities to your organization to prevent file activity associated with these locations from appearing in your security event dashboards, user profiles, and alerts. + +## Get CSV Template + +The following command generates a CSV template to either create, update, or remove multiple trusted activities at once. The CSV file is saved to the current working directory. +```bash +code42 trusted-activities bulk generate-template [create|update|remove] +``` + +You can then fill out and use each of the CSV templates with their respective bulk commands. +```bash +code42 trusted-activities bulk [create|update|remove] bulk-command.csv +``` + +## Add a New Trusted Activity + +Use the `create` command to add a new trusted domain or Slack workspace to your organization's trusted activities. +```bash +code42 trusted-activities create DOMAIN mydomain.com --description "a new trusted activity" +``` + +To add multiple trusted activities at once, enter information about the trusted activity into the `create` CSV file template. +For each activity, the `type` and `value` fields are required. + + `type` indicates the category of activity: + - `DOMAIN` indicates a trusted domain + - `SLACK` indicates a trusted Slack workspace + + `value` indicates either the name of the domain or Slack workspace. + +Then use the `bulk create` command with the CSV file path. For example: +```bash +code42 trusted-activities bulk create create_trusted_activities.csv +``` + +## Update a Trusted Activity + +Use the `update` command to update either the value or description of a single trusted activity. The `resource_id` of the activity is required. The other fields are optional. + +```bash +code42 trusted-activities update 123 --value my-updated-domain.com --description "an updated trusted activity" +``` + +To update multiple trusted activities at once, enter information about the trusted activity into the `update` CSV file template, then use the `bulk update` command with the CSV file path. + +```bash +code42 trusted-activities bulk update update_trusted_activities.csv +``` + +```eval_rst +.. note:: + The ``bulk update`` command cannot be used to clear the description of a trusted activity because you cannot indicate an empty string in a CSV format. + Pass an empty string to the ``description`` option of the ``update`` command to clear the description of a trusted activity. + + For example: ``code42 trusted-activities update 123 --description ""`` +``` + +## Remove a Trusted Activity + +Use the `remove` command to remove a single trusted activity. Only the `resource_id` of an activity is required to remove it. + +```bash +code42 trusted-activities remove 123 +``` + +To remove multiple trusted activities at once, enter information about the trusted activity into the `remove` CSV file template, then use the `bulk remove` command with the CSV file path. + +```bash +code42 trusted-activities bulk remove remove_trusted_activities.csv +``` + +Learn more about the [Trusted Activities](../commands/trustedactivities.md) commands. diff --git a/docs/userguides/users.md b/docs/userguides/users.md new file mode 100644 index 000000000..b308bb18c --- /dev/null +++ b/docs/userguides/users.md @@ -0,0 +1,66 @@ +# Manage Users + +You can use the CLI to manage user information, update user roles, and move users between organizations. + +To view a all the users currently in your organization, you can export a list from the [Users list in the Code42 console](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Users_reference) or you can use the `list` command. + +You can use optional flags to filter the users you want to view. The following command will print all active users with the `Desktop User` role who belong to the organization with UID `1234567890`: +```bash +code42 users list --org-uid 1234567890 --role-name "Desktop User" --active +``` + +To change the information for one or more users, provide the user UID and updated information with the `update` or `bulk update` commands. + +## Manage User Roles + +Apply [Code42's user roles](https://support.code42.com/Administrator/Cloud/Monitoring_and_managing/Roles_resources/Roles_reference#Standard_roles) to user accounts to provide administrators with the desired set of permissions. Each role has associated permissions, limitations, and recommended use cases. + +Use the following command to add a role to a user: +```bash +code42 users add-role --username "sean.cassidy@example.com" --role-name "Desktop User" +``` + +Similarly, use the `remove-role` command to remove a role from a user. + +## Deactivate a User + +You can deactivate a user with the following command: +```bash +code42 users deactivate sean.cassidy@example.com +``` + +To deactivate multiple users at once, enter each username on a new line in a CSV file, then use the `bulk deactivate` command with the CSV file path. For example: +```bash +code42 users bulk deactivate users_to_deactivate.csv +``` + +Similarly, use the `reactivate` and `bulk reactivate` commands to reactivate a user. + +## Assign an Organization + +Use [Organizations](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Organizations_reference) to group users together in the Code42 environment. + +Use the following example command to move a user into an organization associated with the `org_id` 1234567890: +```bash +code42 users move --username sean.cassidy@example.com --org-id 1234567890 +``` + +Alternatively, to move multiple users between organizations, fill out the `move` CSV file template, then use the `bulk move` command with the CSV file path. +```bash +code42 users bulk move bulk-command.csv +``` + +## Get CSV Template + +The following command generates a CSV template to either update users' data, or move users between organizations. The csv file is saved to the current working directory. +```bash +code42 trusted-activities bulk generate-template [update|move] +``` + +Once generated, fill out and use each of the CSV templates with their respective bulk commands. +```bash +code42 trusted-activities bulk [update|move|reactivate|deactivate] bulk-command.csv +``` +A CSV with a `username` column and a single username on each new line is used for the `reactivate` and `deactivate` bulk commands. These commands are not available as options for `generate-template`. + +Learn more about [Managing Users](../commands/users.md). From 93d2ef17c1e4ab32e614187cfd7d13878a7142ae Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 12 Oct 2021 09:06:41 -0500 Subject: [PATCH 279/349] Feature/add user roles view (#329) * trust user guide * adding trust user guides * user guides * adding user guides * user guides * user guides * adding tora.kozic cmd and --include-roles option * PR feedback * PR feedback * user orgs cmds * cmds to view orgs * pass checks * users and orgs integration tests * style * new command docs * naming * wording * PR feedback --- CHANGELOG.md | 12 ++ docs/userguides/users.md | 25 +++- src/code42cli/cmds/users.py | 134 ++++++++++++++--- tests/cmds/test_users.py | 253 +++++++++++++++++++++++++++++++- tests/integration/test_users.py | 19 +++ 5 files changed, 419 insertions(+), 24 deletions(-) create mode 100644 tests/integration/test_users.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e566430..4f8007a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- New option `--include-roles` on `code42 users list` that includes the roles for all users. + +- New command `code42 users show ` that prints all the details of that user. + +- New commands to view orgs + - `code42 users orgs list` + - `code42 users orgs show ` + ## 1.10.0 - 2021-10-05 ### Added diff --git a/docs/userguides/users.md b/docs/userguides/users.md index b308bb18c..a422a9bf4 100644 --- a/docs/userguides/users.md +++ b/docs/userguides/users.md @@ -15,6 +15,18 @@ To change the information for one or more users, provide the user UID and update Apply [Code42's user roles](https://support.code42.com/Administrator/Cloud/Monitoring_and_managing/Roles_resources/Roles_reference#Standard_roles) to user accounts to provide administrators with the desired set of permissions. Each role has associated permissions, limitations, and recommended use cases. +#### View User Roles +View a user's current roles and other details with the `show` command: +```bash +code42 users show "sean.cassidy@example.com" +``` +Alternatively, pass the `--include-roles` flag to the `list ` command. The following command will print a list of all active users and their current roles: +```bash +code42 users list --active --include-roles +``` + +#### Update User Roles + Use the following command to add a role to a user: ```bash code42 users add-role --username "sean.cassidy@example.com" --role-name "Desktop User" @@ -40,7 +52,18 @@ Similarly, use the `reactivate` and `bulk reactivate` commands to reactivate a u Use [Organizations](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Organizations_reference) to group users together in the Code42 environment. -Use the following example command to move a user into an organization associated with the `org_id` 1234567890: +You'll need an organization's unique identifier number (UID) to move a user into it. You can use the `list` command to view a list of all current user organizations, including UIDs: +```bash +code42 users orgs list +``` + +Use the `show` command to view all the details of a user organization. +As an example, to print the details of an organization associated with the UID `123456789` in JSON format: +```bash +code42 users show 123456789 -f JSON +``` + +Once you've identified your organizations UID number, use the `move` command to move a user into that organization. In the following example a user is moved into the organization associated with the UID `1234567890`: ```bash code42 users move --username sean.cassidy@example.com --org-id 1234567890 ``` diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 4975039d8..db497c048 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -1,6 +1,7 @@ import click from pandas import DataFrame from pandas import json_normalize +from py42.exceptions import Py42NotFoundError from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process @@ -17,12 +18,7 @@ from code42cli.worker import create_worker_stats -@click.group(cls=OrderedGroup) -@sdk_options(hidden=True) -def users(state): - """Manage users within your Code42 environment.""" - pass - +username_arg = click.argument("username") org_uid_option = click.option( "--org-uid", @@ -42,9 +38,15 @@ def users(state): ) org_id_option = click.option( "--org-id", - help="The identifier for the organization to which the user will be moved.", + help="The unique identifier (UID) for the organization to which the user will be moved.", required=True, ) +include_legal_hold_option = click.option( + "--include-legal-hold-membership", + default=False, + is_flag=True, + help="Include legal hold membership in output.", +) def role_name_option(help): @@ -55,21 +57,33 @@ def username_option(help, required=False): return click.option("--username", help=help, required=required) +@click.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def users(state): + """Manage users within your Code42 environment.""" + pass + + @users.command(name="list") @org_uid_option @role_name_option("Limit results to only users having the specified role.") @active_option @inactive_option +@include_legal_hold_option @click.option( - "--include-legal-hold-membership", - default=False, - is_flag=True, - help="Include legal hold membership in output.", + "--include-roles", default=False, is_flag=True, help="Include user roles." ) @format_option @sdk_options() def list_users( - state, org_uid, role_name, active, inactive, include_legal_hold_membership, format + state, + org_uid, + role_name, + active, + inactive, + include_legal_hold_membership, + include_roles, + format, ): """List users in your Code42 environment.""" if inactive: @@ -80,7 +94,11 @@ def list_users( if format == OutputFormat.TABLE else None ) - df = _get_users_dataframe(state.sdk, columns, org_uid, role_id, active) + if include_roles and columns: + columns.append("roles") + df = _get_users_dataframe( + state.sdk, columns, org_uid, role_id, active, include_roles + ) if include_legal_hold_membership: df = _add_legal_hold_membership_to_user_dataframe(state.sdk, df) if df.empty: @@ -90,6 +108,29 @@ def list_users( formatter.echo_formatted_dataframe(df) +@users.command("show") +@username_arg +@include_legal_hold_option +@format_option +@sdk_options() +def show_user(state, username, include_legal_hold_membership, format): + """Show user details.""" + columns = ( + ["userUid", "status", "username", "orgUid", "roles"] + if format == OutputFormat.TABLE + else None + ) + response = state.sdk.users.get_by_username(username, incRoles=True) + df = DataFrame.from_records(response["users"], columns=columns) + if include_legal_hold_membership and not df.empty: + df = _add_legal_hold_membership_to_user_dataframe(state.sdk, df) + if df.empty: + click.echo("No results found.") + else: + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframe(df) + + @users.command() @username_option("Username of the target user.") @role_name_option("Name of role to add.") @@ -146,7 +187,7 @@ def update_user( @users.command() -@click.argument("username") +@username_arg @sdk_options() def deactivate(state, username): """Deactivate a user.""" @@ -155,7 +196,7 @@ def deactivate(state, username): @users.command() -@click.argument("username") +@username_arg @sdk_options() def reactivate(state, username): """Reactivate a user.""" @@ -183,10 +224,64 @@ def reactivate(state, username): @sdk_options() def change_organization(state, username, org_id): """Change the organization of the user with the given username - to the org with the given org ID.""" + to the org with the given org UID.""" _change_organization(state.sdk, username, org_id) +@users.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def orgs(state): + """Tools for viewing user orgs.""" + pass + + +def _get_orgs_header(): + return { + "orgId": "ID", + "orgUid": "UID", + "orgName": "Name", + "status": "Status", + "parentOrgId": "Parent ID", + "parentOrgUid": "Parent UID", + "type": "Type", + "classification": "Classification", + "creationDate": "Creation Date", + "settings": "Settings", + } + + +@orgs.command(name="list") +@format_option +@sdk_options() +def list_orgs( + state, format, +): + """List all orgs.""" + pages = state.sdk.orgs.get_all() + formatter = OutputFormatter(format, _get_orgs_header()) + orgs = [org for page in pages for org in page["orgs"]] + if orgs: + formatter.echo_formatted_list(orgs) + else: + click.echo("No orgs found.") + + +@orgs.command(name="show") +@click.argument("org-uid") +@format_option +@sdk_options() +def show_org( + state, org_uid, format, +): + """Show org details.""" + formatter = OutputFormatter(format) + try: + response = state.sdk.orgs.get_by_uid(org_uid) + formatter.echo_formatted_list([response.data]) + except Py42NotFoundError: + raise Code42CLIError(f"Invalid org UID {org_uid}.") + + @users.group(cls=OrderedGroup) @sdk_options(hidden=True) def bulk(state): @@ -400,8 +495,10 @@ def _get_role_id(sdk, role_name): raise Code42CLIError(f"Role with name '{role_name}' not found.") -def _get_users_dataframe(sdk, columns, org_uid, role_id, active): - users_generator = sdk.users.get_all(active=active, org_uid=org_uid, role_id=role_id) +def _get_users_dataframe(sdk, columns, org_uid, role_id, active, include_roles): + users_generator = sdk.users.get_all( + active=active, org_uid=org_uid, role_id=role_id, incRoles=include_roles + ) users_list = [] for page in users_generator: users_list.extend(page["users"]) @@ -413,6 +510,7 @@ def _add_legal_hold_membership_to_user_dataframe(sdk, df): columns = ["legalHold.legalHoldUid", "legalHold.name", "user.userUid"] custodians = list(_get_all_active_hold_memberships(sdk)) + if len(custodians) == 0: return df diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 1af78ec18..79648c9b4 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -1,8 +1,11 @@ +import json + import pytest from py42.exceptions import Py42ActiveLegalHoldError from py42.exceptions import Py42InvalidEmailError from py42.exceptions import Py42InvalidPasswordError from py42.exceptions import Py42InvalidUsernameError +from py42.exceptions import Py42NotFoundError from py42.exceptions import Py42OrgNotFoundError from tests.conftest import create_mock_http_error from tests.conftest import create_mock_response @@ -28,6 +31,7 @@ "blocked": False, "creationDate": "2021-03-12T20:07:40.898Z", "modificationDate": "2021-03-12T20:07:40.938Z", + "roles": ["Desktop User"], "userId": 1234, "username": "test.username@example.com", "userUid": "911162111513111325", @@ -104,6 +108,9 @@ "reporting": {"orgManagers": []}, "customConfig": False, } +TEST_EMPTY_ORGS_RESPONSE = {"totalCount": 0, "orgs": []} +TEST_GET_ALL_ORGS_RESPONSE = {"totalCount": 1, "orgs": [TEST_GET_ORG_RESPONSE]} +TEST_ORG_UID = "1007759454961904673" @pytest.fixture @@ -136,6 +143,22 @@ def get_org_success(cli_state, get_org_response): cli_state.sdk.orgs.get_by_uid.return_value = get_org_response +@pytest.fixture +def get_all_orgs_empty_success(mocker, cli_state): + def get_all_orgs_empty_generator(): + yield create_mock_response(mocker, data=json.dumps(TEST_EMPTY_ORGS_RESPONSE)) + + cli_state.sdk.orgs.get_all.return_value = get_all_orgs_empty_generator() + + +@pytest.fixture +def get_all_orgs_success(mocker, cli_state): + def get_all_orgs_generator(): + yield create_mock_response(mocker, data=json.dumps(TEST_GET_ALL_ORGS_RESPONSE)) + + cli_state.sdk.orgs.get_all.return_value = get_all_orgs_generator() + + @pytest.fixture def get_all_users_success(mocker, cli_state): def get_all_users_generator(): @@ -288,7 +311,7 @@ def test_list_users_calls_users_get_all_with_expected_role_id( role_name = "Customer Cloud Admin" runner.invoke(cli, ["users", "list", "--role-name", role_name], obj=cli_state) cli_state.sdk.users.get_all.assert_called_once_with( - active=None, org_uid=None, role_id="1234543" + active=None, org_uid=None, role_id="1234543", incRoles=False ) @@ -297,10 +320,12 @@ def test_list_users_calls_get_all_users_with_correct_parameters( ): org_uid = "TEST_ORG_UID" runner.invoke( - cli, ["users", "list", "--org-uid", org_uid, "--active"], obj=cli_state + cli, + ["users", "list", "--org-uid", org_uid, "--active", "--include-roles"], + obj=cli_state, ) cli_state.sdk.users.get_all.assert_called_once_with( - active=True, org_uid=org_uid, role_id=None + active=True, org_uid=org_uid, role_id=None, incRoles=True ) @@ -309,7 +334,7 @@ def test_list_users_when_given_inactive_uses_active_equals_false( ): runner.invoke(cli, ["users", "list", "--inactive"], obj=cli_state) cli_state.sdk.users.get_all.assert_called_once_with( - active=False, org_uid=None, role_id=None + active=False, org_uid=None, role_id=None, incRoles=False ) @@ -327,7 +352,7 @@ def test_list_users_when_given_excluding_active_and_inactive_uses_active_equals_ ): runner.invoke(cli, ["users", "list"], obj=cli_state) cli_state.sdk.users.get_all.assert_called_once_with( - active=None, org_uid=None, role_id=None + active=None, org_uid=None, role_id=None, incRoles=False ) @@ -407,6 +432,122 @@ def test_list_include_legal_hold_membership_merges_in_and_concats_legal_hold_inf assert "123456789,987654321" in result.output +def test_list_prints_expected_data_if_include_roles( + runner, cli_state, get_all_users_success +): + result = runner.invoke(cli, ["users", "list", "--include-roles"], obj=cli_state) + assert "roles" in result.output + assert "Desktop User" in result.output + + +def test_show_calls_get_by_username_with_expected_params(runner, cli_state): + runner.invoke( + cli, ["users", "show", "test.username@example.com"], obj=cli_state, + ) + cli_state.sdk.users.get_by_username.assert_called_once_with( + "test.username@example.com", incRoles=True + ) + + +def test_show_prints_expected_data(runner, cli_state, get_users_response): + cli_state.sdk.users.get_by_username.return_value = get_users_response + result = runner.invoke( + cli, ["users", "show", "test.username@example.com"], obj=cli_state, + ) + assert "test.username@example.com" in result.output + assert "911162111513111325" in result.output + assert "Active" in result.output + assert "44444444" in result.output + assert "Desktop User" in result.output + + +def test_show_legal_hold_flag_reports_none_for_users_not_on_legal_hold( + runner, + cli_state, + get_users_response, + get_custodian_failure, + get_all_matter_success, +): + cli_state.sdk.users.get_by_username.return_value = get_users_response + result = runner.invoke( + cli, + [ + "users", + "show", + "test.username@example.com", + "--include-legal-hold-membership", + "-f", + "CSV", + ], + obj=cli_state, + ) + + assert "Legal Hold #1,Legal Hold #2" not in result.output + assert "123456789,987654321" not in result.output + assert "legalHoldUid" not in result.output + assert "test.username@example.com" in result.output + + +def test_show_legal_hold_flag_reports_none_if_no_matters_exist( + runner, cli_state, get_users_response, get_custodian_failure, get_matter_failure +): + cli_state.sdk.users.get_by_username.return_value = get_users_response + result = runner.invoke( + cli, + [ + "users", + "show", + "test.username@example.com", + "--include-legal-hold-membership", + ], + obj=cli_state, + ) + + assert "Legal Hold #1,Legal Hold #2" not in result.output + assert "123456789,987654321" not in result.output + assert "legalHoldUid" not in result.output + assert "test.username@example.com" in result.output + + +def test_show_legal_hold_values_not_included_for_legal_hold_user_if_legal_hold_flag_not_passed( + runner, + cli_state, + get_users_response, + get_all_custodian_success, + get_all_matter_success, +): + cli_state.sdk.users.get_by_username.return_value = get_users_response + result = runner.invoke( + cli, ["users", "show", "test.username@example.com"], obj=cli_state + ) + assert "Legal Hold #1,Legal Hold #2" not in result.output + assert "123456789,987654321" not in result.output + assert "test.username@example.com" in result.output + + +def test_show_include_legal_hold_membership_merges_in_and_concats_legal_hold_info( + runner, + cli_state, + get_users_response, + get_all_custodian_success, + get_all_matter_success, +): + cli_state.sdk.users.get_by_username.return_value = get_users_response + result = runner.invoke( + cli, + [ + "users", + "show", + "test.username@example.com", + "--include-legal-hold-membership", + ], + obj=cli_state, + ) + + assert "Legal Hold #1,Legal Hold #2" in result.output + assert "123456789,987654321" in result.output + + def test_add_user_role_adds( runner, cli_state, get_user_id_success, get_available_roles_success ): @@ -965,3 +1106,105 @@ def _get(username, *args, **kwargs): handler(username="test@example.com") handler(username="not.test@example.com") assert worker_stats.increment_total_errors.call_count == 1 + + +def test_orgs_list_calls_orgs_get_all_with_expected_params(runner, cli_state): + runner.invoke(cli, ["users", "orgs", "list"], obj=cli_state) + assert cli_state.sdk.orgs.get_all.call_count == 1 + + +def test_orgs_list_prints_no_results_if_no_orgs_found( + runner, cli_state, get_all_orgs_empty_success +): + result = runner.invoke(cli, ["users", "orgs", "list"], obj=cli_state) + assert "No orgs found." in result.output + + +def test_orgs_list_prints_expected_data(runner, cli_state, get_all_orgs_success): + result = runner.invoke(cli, ["users", "orgs", "list"], obj=cli_state) + assert "9087" in result.output + assert "1007759454961904673" in result.output + assert "19may" in result.output + assert "Active" in result.output + assert "2689" in result.output + assert "890854247383106706" in result.output + assert "ENTERPRISE" in result.output + assert "BASIC" in result.output + assert "2021-05-19T10:10:43.459Z" in result.output + assert "{'maxSeats': None, 'maxBytes': None}" in result.output + + +def test_orgs_list_prints_all_data_fields_when_not_table_format( + runner, cli_state, get_all_orgs_success +): + result = runner.invoke(cli, ["users", "orgs", "list", "-f", "JSON"], obj=cli_state) + for k, _v in TEST_GET_ORG_RESPONSE.items(): + assert k in result.output + assert "9087" in result.output + assert "1007759454961904673" in result.output + assert "19may" in result.output + assert "Active" in result.output + assert "2689" in result.output + assert "890854247383106706" in result.output + assert "ENTERPRISE" in result.output + assert "BASIC" in result.output + assert "2021-05-19T10:10:43.459Z" in result.output + assert '"maxSeats": null' in result.output + assert '"maxSeats": null' in result.output + + +def test_orgs_show_calls_orgs_get_by_uid_with_expected_params( + runner, cli_state, +): + runner.invoke(cli, ["users", "orgs", "show", TEST_ORG_UID], obj=cli_state) + cli_state.sdk.orgs.get_by_uid.assert_called_once_with(TEST_ORG_UID) + + +def test_orgs_show_exits_and_returns_error_if_uid_arg_not_provided(runner, cli_state): + result = runner.invoke(cli, ["users", "orgs", "show"], obj=cli_state) + assert result.exit_code == 2 + assert "Error: Missing argument 'ORG_UID'." in result.output + + +def test_orgs_show_prints_expected_data( + runner, cli_state, get_org_success, +): + result = runner.invoke(cli, ["users", "orgs", "show", TEST_ORG_UID], obj=cli_state) + assert "9087" in result.output + assert "1007759454961904673" in result.output + assert "19may" in result.output + assert "Active" in result.output + assert "2689" in result.output + assert "890854247383106706" in result.output + assert "ENTERPRISE" in result.output + assert "BASIC" in result.output + assert "2021-05-19T10:10:43.459Z" in result.output + assert "{'maxSeats': None, 'maxBytes': None}" in result.output + + +def test_orgs_show_prints_all_data_fields_when_not_table_format( + runner, cli_state, get_org_success, +): + result = runner.invoke( + cli, ["users", "orgs", "show", TEST_ORG_UID, "-f", "JSON"], obj=cli_state + ) + for k, _v in TEST_GET_ORG_RESPONSE.items(): + assert k in result.output + assert "9087" in result.output + assert "1007759454961904673" in result.output + assert "19may" in result.output + assert "Active" in result.output + assert "2689" in result.output + assert "890854247383106706" in result.output + assert "ENTERPRISE" in result.output + assert "BASIC" in result.output + assert "2021-05-19T10:10:43.459Z" in result.output + assert '"maxSeats": null' in result.output + assert '"maxSeats": null' in result.output + + +def test_orgs_show_when_invalid_org_uid_raises_error(runner, cli_state, custom_error): + cli_state.sdk.orgs.get_by_uid.side_effect = Py42NotFoundError(custom_error) + result = runner.invoke(cli, ["users", "orgs", "show", TEST_ORG_UID], obj=cli_state) + assert result.exit_code == 1 + assert f"Invalid org UID {TEST_ORG_UID}." in result.output diff --git a/tests/integration/test_users.py b/tests/integration/test_users.py new file mode 100644 index 000000000..24c3f4dcb --- /dev/null +++ b/tests/integration/test_users.py @@ -0,0 +1,19 @@ +import pytest +from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful + + +@pytest.mark.integration +def test_users_list_command_returns_success_return_code( + runner, integration_test_profile +): + command = "users list" + assert_test_is_successful(runner, append_profile(command)) + + +@pytest.mark.integration +def test_users_orgs_list_command_returns_success_return_code( + runner, integration_test_profile +): + command = "users orgs list" + assert_test_is_successful(runner, append_profile(command)) From 386f87fe25d876db1ab386516f2d82a918522632 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 12 Oct 2021 16:27:03 -0500 Subject: [PATCH 280/349] Chore/remove extractor (#320) * removing extractor * removing extractor * removing extractor from file event search * search tests * removing extractor * removing extractor from alerts search * removing extractor * removing extractor * making security-data tests compatible with python 3.7 * making alert test compatible with python 3.7 * returning event responses as generator * reverting securitydata changes * moving checkpoint timestamp check * style check * documentation changes * converting to event generator * using generator to return file events * indent * adding dedupe logic to alert checkpointing * black * return none of checkpoint file empty * additional integration test for security-data cmds * decrease default alert batch to 25 * style * removing filter check * removing 'get_alert_details' method * PR Feedback * use get_all_alert_details py42 method * PR feedback * delete unused test constant * implement py42 bugfix * ALERT_PAGE_SIZE constant * adjust unit test * ? * cleaning up code --- setup.py | 3 +- src/code42cli/cmds/alerts.py | 136 +++++-- src/code42cli/cmds/search/cursor_store.py | 42 ++- src/code42cli/cmds/search/extraction.py | 165 --------- src/code42cli/cmds/search/options.py | 12 +- src/code42cli/cmds/securitydata.py | 165 ++++++--- src/code42cli/cmds/util.py | 89 +++++ src/code42cli/options.py | 6 +- src/code42cli/output_formats.py | 3 + tests/cmds/conftest.py | 12 +- tests/cmds/search/test_cursor_store.py | 8 +- tests/cmds/search/test_extraction.py | 86 ----- tests/cmds/test_alerts.py | 377 ++++++++++++------- tests/cmds/test_securitydata.py | 429 +++++++++++++++------- tests/cmds/test_trustedactivities.py | 2 +- tests/cmds/test_util.py | 36 ++ tests/integration/test_securitydata.py | 31 +- tox.ini | 1 - 18 files changed, 969 insertions(+), 634 deletions(-) delete mode 100644 src/code42cli/cmds/search/extraction.py create mode 100644 src/code42cli/cmds/util.py delete mode 100644 tests/cmds/search/test_extraction.py create mode 100644 tests/cmds/test_util.py diff --git a/setup.py b/setup.py index 629e5ce28..44c9bae4b 100644 --- a/setup.py +++ b/setup.py @@ -35,12 +35,11 @@ "click>=7.1.1, <8", "click_plugins>=1.1.1", "colorama>=0.4.3", - "c42eventextractor==0.4.1", "keyring==18.0.1", "keyrings.alt==3.2.0", "ipython>=7.16.1", "pandas>=1.1.3", - "py42>=1.19.0", + "py42>=1.19.1", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index feb72f75e..b51457372 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -1,13 +1,12 @@ import click import py42.sdk.queries.alerts.filters as f -from c42eventextractor.extractors import AlertExtractor from py42.exceptions import Py42NotFoundError +from py42.sdk.queries.alerts.alert_query import AlertQuery from py42.sdk.queries.alerts.filters import AlertState from py42.sdk.queries.alerts.filters import RuleType from py42.sdk.queries.alerts.filters import Severity from py42.util import format_dict -import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt import code42cli.errors as errors import code42cli.options as opt @@ -16,8 +15,10 @@ from code42cli.click_ext.groups import OrderedGroup from code42cli.cmds.search import SendToCommand from code42cli.cmds.search.cursor_store import AlertCursorStore -from code42cli.cmds.search.extraction import handle_no_events from code42cli.cmds.search.options import server_options +from code42cli.cmds.util import convert_to_or_query +from code42cli.cmds.util import create_time_range_filter +from code42cli.cmds.util import try_get_default_header from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import limit_date_range from code42cli.file_readers import read_csv_arg @@ -25,9 +26,13 @@ from code42cli.output_formats import JsonOutputFormat from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter - +from code42cli.util import hash_event +from code42cli.util import parse_timestamp +from code42cli.util import warn_interrupt ALERTS_KEYWORD = "alerts" +ALERT_PAGE_SIZE = 25 + begin = opt.begin_option( ALERTS_KEYWORD, callback=lambda ctx, param, arg: convert_datetime_to_timestamp( @@ -202,20 +207,6 @@ def clear_checkpoint(state, checkpoint_name): _get_alert_cursor_store(state.profile.name).delete(checkpoint_name) -def _call_extractor( - cli_state, handlers, begin, end, or_query, advanced_query, **kwargs -): - extractor = _get_alert_extractor(cli_state.sdk, handlers) - extractor.use_or_query = or_query - if advanced_query: - cli_state.search_filters = advanced_query - if begin or end: - cli_state.search_filters.append( - ext.create_time_range_filter(f.DateObserved, begin, end) - ) - extractor.extract(*cli_state.search_filters) - - @alerts.command() @filter_options @search_options @@ -242,21 +233,78 @@ def search( **kwargs, ): """Search for alerts.""" - output_header = ext.try_get_default_header( + output_header = try_get_default_header( include_all, _get_default_output_header(), format ) formatter = OutputFormatter(format, output_header) cursor = _get_alert_cursor_store(cli_state.profile.name) if use_checkpoint else None - handlers = ext.create_handlers( - cli_state.sdk, - AlertExtractor, - cursor, - use_checkpoint, - formatter=formatter, - force_pager=include_all, - ) - _call_extractor(cli_state, handlers, begin, end, or_query, advanced_query, **kwargs) - handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) + if use_checkpoint: + checkpoint_name = use_checkpoint + checkpoint = cursor.get(checkpoint_name) + if checkpoint is not None: + begin = checkpoint + + query = _construct_query(cli_state, begin, end, advanced_query, or_query) + alerts_gen = cli_state.sdk.alerts.get_all_alert_details(query) + + if use_checkpoint: + checkpoint_name = use_checkpoint + # update checkpoint to alertId of last event retrieved + alerts_gen = _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, alerts_gen + ) + alerts_list = [] + for alert in alerts_gen: + alerts_list.append(alert) + if not alerts_list: + click.echo("No results found.") + return + formatter.echo_formatted_list(alerts_list) + + +def _construct_query(state, begin, end, advanced_query, or_query): + + if advanced_query: + state.search_filters = advanced_query + else: + if begin or end: + state.search_filters.append( + create_time_range_filter(f.DateObserved, begin, end) + ) + if or_query: + state.search_filters = convert_to_or_query(state.search_filters) + query = AlertQuery(*state.search_filters) + query.page_size = ALERT_PAGE_SIZE + query.sort_direction = "asc" + query.sort_key = "CreatedAt" + return query + + +def _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, alerts_gen +): + """De-duplicates events across checkpointed runs. Since using the timestamp of the last event + processed as the `--begin` time of the next run causes the last event to show up again in the + next results, we hash the last event(s) of each run and store those hashes in the cursor to + filter out on the next run. It's also possible that two events have the exact same timestamp, so + `checkpoint_events` needs to be a list of hashes so we can filter out everything that's actually + been processed. + """ + + checkpoint_alerts = cursor.get_alerts(checkpoint_name) + new_timestamp = None + new_alerts = [] + for alert in alerts_gen: + event_hash = hash_event(alert) + if event_hash not in checkpoint_alerts: + if alert[f.DateObserved._term] != new_timestamp: + new_timestamp = alert[f.DateObserved._term] + new_alerts.clear() + new_alerts.append(event_hash) + yield alert + ts = parse_timestamp(new_timestamp) + cursor.replace(checkpoint_name, ts) + cursor.replace_alerts(checkpoint_name, new_alerts) @alerts.command(cls=SendToCommand) @@ -280,19 +328,31 @@ def send_to(cli_state, begin, end, advanced_query, use_checkpoint, or_query, **k HOSTNAME format: address:port where port is optional and defaults to 514. """ cursor = _get_cursor(cli_state, use_checkpoint) - handlers = ext.create_send_to_handlers( - cli_state.sdk, AlertExtractor, cursor, use_checkpoint, cli_state.logger, - ) - _call_extractor(cli_state, handlers, begin, end, or_query, advanced_query, **kwargs) - handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) + if use_checkpoint: + checkpoint_name = use_checkpoint + checkpoint = cursor.get(checkpoint_name) + if checkpoint is not None: + begin = checkpoint -def _get_cursor(state, use_checkpoint): - return _get_alert_cursor_store(state.profile.name) if use_checkpoint else None + query = _construct_query(cli_state, begin, end, advanced_query, or_query) + alerts_gen = cli_state.sdk.alerts.get_all_alert_details(query) + + if use_checkpoint: + checkpoint_name = use_checkpoint + alerts_gen = _dedupe_checkpointed_events_and_store_updated_checkpoint( + cursor, checkpoint_name, alerts_gen + ) + with warn_interrupt(): + alert = None + for alert in alerts_gen: + cli_state.logger.info(alert) + if alert is None: # generator was empty + click.echo("No results found.") -def _get_alert_extractor(sdk, handlers): - return AlertExtractor(sdk, handlers) +def _get_cursor(state, use_checkpoint): + return _get_alert_cursor_store(state.profile.name) if use_checkpoint else None def _get_alert_cursor_store(profile_name): diff --git a/src/code42cli/cmds/search/cursor_store.py b/src/code42cli/cmds/search/cursor_store.py index e02fcd321..5255dae65 100644 --- a/src/code42cli/cmds/search/cursor_store.py +++ b/src/code42cli/cmds/search/cursor_store.py @@ -30,15 +30,23 @@ def get(self, cursor_name): try: location = path.join(self._dir_path, cursor_name) with open(location) as checkpoint: - return float(checkpoint.read()) + checkpoint_value = checkpoint.read() + if not checkpoint_value: + return None + try: + return float(checkpoint_value) + except ValueError: + raise Code42CLIError( + f"Unable to parse checkpoint from {location}, expected a unix-epoch timestamp, got '{checkpoint_value}'." + ) except FileNotFoundError: return None - def replace(self, cursor_name, new_timestamp): + def replace(self, cursor_name, new_checkpoint): """Replaces the last stored date observed timestamp with the given one.""" location = path.join(self._dir_path, cursor_name) with open(location, "w") as checkpoint: - checkpoint.write(str(new_timestamp)) + checkpoint.write(str(new_checkpoint)) def delete(self, cursor_name): """Removes a single cursor from the store.""" @@ -69,12 +77,40 @@ def __init__(self, profile_name): dir_path = get_user_project_path("file_event_checkpoints", profile_name) super().__init__(dir_path) + def get(self, cursor_name): + """Gets the last stored date observed timestamp.""" + try: + location = path.join(self._dir_path, cursor_name) + with open(location) as checkpoint: + checkpoint_value = checkpoint.read() + if not checkpoint_value: + return None + return str(checkpoint_value) + except FileNotFoundError: + return None + class AlertCursorStore(BaseCursorStore): def __init__(self, profile_name): dir_path = get_user_project_path("alert_checkpoints", profile_name) super().__init__(dir_path) + def get_alerts(self, cursor_name): + try: + location = path.join(self._dir_path, cursor_name) + "_alerts" + with open(location) as checkpoint: + try: + return json.loads(checkpoint.read()) + except json.JSONDecodeError: + return [] + except FileNotFoundError: + return [] + + def replace_alerts(self, cursor_name, new_alerts): + location = path.join(self._dir_path, cursor_name) + "_alerts" + with open(location, "w") as checkpoint: + checkpoint.write(json.dumps(new_alerts)) + class AuditLogCursorStore(BaseCursorStore): def __init__(self, profile_name): diff --git a/src/code42cli/cmds/search/extraction.py b/src/code42cli/cmds/search/extraction.py deleted file mode 100644 index bcca9b297..000000000 --- a/src/code42cli/cmds/search/extraction.py +++ /dev/null @@ -1,165 +0,0 @@ -import json - -import click -from c42eventextractor import ExtractionHandlers -from click import secho -from py42.sdk.queries.query_filter import QueryFilterTimestampField - -import code42cli.errors as errors -from code42cli.date_helper import verify_timestamp_order -from code42cli.logger import get_main_cli_logger -from code42cli.output_formats import OutputFormat -from code42cli.util import warn_interrupt - -logger = get_main_cli_logger() - -_ALERT_DETAIL_BATCH_SIZE = 100 -INTERRUPT_WARNING = ( - "Attempting to cancel cleanly to keep checkpoint data accurate. One moment..." -) - - -def try_get_default_header(include_all, default_header, output_format): - """Returns appropriate header based on include-all and output format. If returns None, - the CLI format option will figure out the header based on the data keys.""" - output_header = None if include_all else default_header - if output_format != OutputFormat.TABLE and include_all: - err_text = "--include-all only allowed for Table output format." - logger.log_error(err_text) - raise errors.Code42CLIError(err_text) - return output_header - - -def _get_alert_details(sdk, alert_summary_list): - alert_ids = [alert["id"] for alert in alert_summary_list] - batches = [ - alert_ids[i : i + _ALERT_DETAIL_BATCH_SIZE] - for i in range(0, len(alert_ids), _ALERT_DETAIL_BATCH_SIZE) - ] - results = [] - for batch in batches: - r = sdk.alerts.get_details(batch) - results.extend(r["alerts"]) - results = sorted(results, key=lambda x: x["createdAt"], reverse=True) - return results - - -def _set_handlers(cursor_store, checkpoint_name): - handlers = ExtractionHandlers() - handlers.TOTAL_EVENTS = 0 - - def handle_error(exception): - if isinstance(exception, OSError): # let click handle it - raise - - errors.ERRORED = True - if hasattr(exception, "response") and hasattr(exception.response, "text"): - message = f"{exception}: {exception.response.text}" - else: - message = exception - logger.log_error(message) - - message = str(message) - if not message.lower().startswith("error:"): - message = f"Error: {message}" - - secho(message, err=True, fg="red") - - handlers.handle_error = handle_error - if cursor_store: - handlers.record_cursor_position = lambda value: cursor_store.replace( - checkpoint_name, value - ) - handlers.get_cursor_position = lambda: cursor_store.get(checkpoint_name) - return handlers - - -def _get_events(sdk, handlers, extractor_key, response): - response_dict = json.loads(response.text) - events = response_dict.get(extractor_key) - if extractor_key == "alerts": - try: - events = _get_alert_details(sdk, events) - except Exception as ex: - handlers.handle_error(ex) - return events - - -def _record_timestamp(extractor, handlers, event): - last_event_timestamp = extractor._get_timestamp_from_item(event) - handlers.record_cursor_position(last_event_timestamp) - - -def create_handlers( - sdk, extractor_class, cursor_store, checkpoint_name, formatter, force_pager -): - extractor = extractor_class(sdk, ExtractionHandlers()) - handlers = _set_handlers(cursor_store, checkpoint_name) - - @warn_interrupt(warning=INTERRUPT_WARNING) - def handle_response(response): - events = _get_events(sdk, handlers, extractor._key, response) - total_events = len(events) - handlers.TOTAL_EVENTS += total_events - formatter.echo_formatted_list(events, force_pager=force_pager) - - # To make sure the extractor records correct timestamp event when `CTRL-C` is pressed. - if total_events: - _record_timestamp(extractor, handlers, events[-1]) - - handlers.handle_response = handle_response - return handlers - - -def create_time_range_filter(filter_cls, begin_date=None, end_date=None): - """Creates a filter using the given filter class (must be a subclass of - :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) and date args. Returns - `None` if both begin_date and end_date args are `None`. - - Args: - filter_cls: The class of filter to create. (must be a subclass of - :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) - begin_date: The begin date for the range. - end_date: The end date for the range. - """ - if not issubclass(filter_cls, QueryFilterTimestampField): - raise Exception("filter_cls must be a subclass of QueryFilterTimestampField") - - if begin_date and end_date: - verify_timestamp_order(begin_date, end_date) - return filter_cls.in_range(begin_date, end_date) - - elif begin_date and not end_date: - return filter_cls.on_or_after(begin_date) - - elif end_date and not begin_date: - return filter_cls.on_or_before(end_date) - - -def create_send_to_handlers( - sdk, extractor_class, cursor_store, checkpoint_name, logger -): - extractor = extractor_class(sdk, ExtractionHandlers()) - handlers = _set_handlers(cursor_store, checkpoint_name) - - @warn_interrupt(warning=INTERRUPT_WARNING) - def handle_response(response): - events = _get_events(sdk, handlers, extractor._key, response) - - total_events = len(events) - handlers.TOTAL_EVENTS += total_events - - for event in events: - logger.info(event) - - # To make sure the extractor records correct timestamp event when `CTRL-C` is pressed. - if total_events: - _record_timestamp(extractor, handlers, events[-1]) - - handlers.handle_response = handle_response - return handlers - - -def handle_no_events(no_events): - if no_events: - click.echo("No results found.") diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index 79c920354..5055b593a 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -1,6 +1,5 @@ import json from datetime import datetime -from datetime import timezone import click from py42.sdk.queries.query_filter import FilterGroup @@ -91,12 +90,15 @@ def handle_parse_result(self, ctx, opts, args): and begin_present ): opts.pop("begin") - checkpoint_value_str = datetime.fromtimestamp( - checkpoint_value, timezone.utc - ).isoformat() + try: + checkpoint_value = datetime.utcfromtimestamp( + float(checkpoint_value) + ) + except ValueError: + pass click.echo( "Ignoring --begin value as --use-checkpoint was passed and checkpoint of " - f"{checkpoint_value_str} exists.\n", + f"{checkpoint_value} exists.\n", err=True, ) if ( diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index e0cd582cb..42eb70618 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -2,33 +2,39 @@ import click import py42.sdk.queries.fileevents.filters as f -from c42eventextractor.extractors import FileEventExtractor from click import echo +from py42.exceptions import Py42InvalidPageTokenError +from py42.sdk.queries.fileevents.file_event_query import FileEventQuery +from py42.sdk.queries.fileevents.filters import InsertionTimestamp from py42.sdk.queries.fileevents.filters.exposure_filter import ExposureType from py42.sdk.queries.fileevents.filters.file_filter import FileCategory from py42.sdk.queries.fileevents.filters.risk_filter import RiskIndicator from py42.sdk.queries.fileevents.filters.risk_filter import RiskSeverity -import code42cli.cmds.search.extraction as ext import code42cli.cmds.search.options as searchopt -import code42cli.errors as errors import code42cli.options as opt from code42cli.click_ext.groups import OrderedGroup from code42cli.click_ext.options import incompatible_with from code42cli.click_ext.types import MapChoice from code42cli.cmds.search import SendToCommand from code42cli.cmds.search.cursor_store import FileEventCursorStore -from code42cli.cmds.search.extraction import handle_no_events from code42cli.cmds.search.options import send_to_format_options from code42cli.cmds.search.options import server_options +from code42cli.cmds.util import convert_to_or_query +from code42cli.cmds.util import create_time_range_filter +from code42cli.cmds.util import try_get_default_header from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import limit_date_range +from code42cli.logger import get_main_cli_logger from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import FileEventsOutputFormat from code42cli.output_formats import FileEventsOutputFormatter from code42cli.output_formats import OutputFormatter +from code42cli.util import warn_interrupt +logger = get_main_cli_logger() +MAX_EVENT_PAGE_SIZE = 10000 SECURITY_DATA_KEYWORD = "file events" file_events_format_option = click.option( @@ -263,7 +269,8 @@ def _get_saved_search_query(ctx, param, arg): return click.option( "--saved-search", - help="Get events from a saved search filter with the given ID.", + help="Get events from a saved search filter with the given ID." + "WARNING: Using a saved search is incompatible with other query-building arguments.", callback=_get_saved_search_query, cls=incompatible_with("advanced_query"), ) @@ -358,23 +365,81 @@ def search( include_all, **kwargs, ): + """Search for file events.""" - output_header = ext.try_get_default_header( + output_header = try_get_default_header( include_all, _create_search_header_map(), format ) formatter = FileEventsOutputFormatter(format, output_header) cursor = _get_cursor(state, use_checkpoint) - handlers = ext.create_handlers( - state.sdk, - FileEventExtractor, - cursor, - use_checkpoint, - formatter=formatter, - force_pager=include_all, - ) - _extract( - state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs - ) + if use_checkpoint: + checkpoint_name = use_checkpoint + # if checkpoint name exists, checkpoint should be that eventId, + # otherwise it should set the initial value to "" + checkpoint = cursor.get(checkpoint_name) or "" + + # older app versions stored checkpoint as float timestamp. + # we handle those here until the next run containing events will store checkpoint as the last eventId + try: + state.search_filters.append( + InsertionTimestamp.on_or_after(float(checkpoint)) + ) + checkpoint = "" + except ValueError: + pass + else: + checkpoint = "" + + query = _construct_query(state, begin, end, saved_search, advanced_query, or_query) + events = _get_all_file_events(state, query, checkpoint) + + if use_checkpoint: + checkpoint_name = use_checkpoint + events = _store_updated_checkpoint(cursor, checkpoint_name, events) + + events_list = [] + for event in events: + events_list.append(event) + if not events_list: + click.echo("No results found.") + return + formatter.echo_formatted_list(events_list) + + +def _construct_query(state, begin, end, saved_search, advanced_query, or_query): + + if advanced_query: + state.search_filters = advanced_query + if saved_search: + state.search_filters = saved_search._filter_group_list + else: + if begin or end: + state.search_filters.append( + create_time_range_filter(f.EventTimestamp, begin, end) + ) + if or_query: + state.search_filters = convert_to_or_query(state.search_filters) + query = FileEventQuery(*state.search_filters) + query.page_size = MAX_EVENT_PAGE_SIZE + query.sort_direction = "asc" + query.sort_key = "insertionTimestamp" + return query + + +def _get_all_file_events(state, query, checkpoint=""): + + try: + response = state.sdk.securitydata.search_all_file_events( + query, page_token=checkpoint + ) + except Py42InvalidPageTokenError: + response = state.sdk.securitydata.search_all_file_events(query) + yield from response["fileEvents"] + while response["nextPgToken"]: + response = state.sdk.securitydata.search_all_file_events( + query, page_token=response["nextPgToken"] + ) + yield from response["fileEvents"] @security_data.group(cls=OrderedGroup) @@ -431,16 +496,37 @@ def send_to( HOSTNAME format: address:port where port is optional and defaults to 514. """ cursor = _get_cursor(state, use_checkpoint) - handlers = ext.create_send_to_handlers( - state.sdk, FileEventExtractor, cursor, use_checkpoint, state.logger - ) - _extract( - state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs - ) + if use_checkpoint: + checkpoint_name = use_checkpoint + # if checkpoint name exists, checkpoint should be that eventId, + # otherwise it should set the initial value to "" + checkpoint = cursor.get(checkpoint_name) or "" + + # older app versions stored checkpoint as float timestamp. + # we handle those here until the next run containing events will store checkpoint as the last eventId + try: + state.search_filters.append( + InsertionTimestamp.on_or_after(float(checkpoint)) + ) + checkpoint = "" + except ValueError: + pass + else: + checkpoint = "" + + query = _construct_query(state, begin, end, saved_search, advanced_query, or_query) + events = _get_all_file_events(state, query, checkpoint) -def _get_file_event_extractor(sdk, handlers): - return FileEventExtractor(sdk, handlers) + with warn_interrupt(): + event = None + for event in events: + if use_checkpoint: + checkpoint_name = use_checkpoint + cursor.replace(checkpoint_name, event["eventId"]) + state.logger.info(event) + if event is None: # generator was empty + click.echo("No results found.") def _get_cursor(state, use_checkpoint): @@ -451,28 +537,7 @@ def _get_file_event_cursor_store(profile_name): return FileEventCursorStore(profile_name) -def _extract( - state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs -): - _call_extractor( - state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs - ) - handle_no_events(not handlers.TOTAL_EVENTS and not errors.ERRORED) - - -def _call_extractor( - state, handlers, begin, end, or_query, advanced_query, saved_search, **kwargs -): - if advanced_query: - state.search_filters = advanced_query - extractor = _get_file_event_extractor(state.sdk, handlers) - extractor.use_or_query = or_query - extractor.or_query_exempt_filters.append(f.ExposureType.exists()) - if saved_search: - extractor.extract(*saved_search._filter_group_list) - else: - if begin or end: - state.search_filters.append( - ext.create_time_range_filter(f.EventTimestamp, begin, end) - ) - extractor.extract(*state.search_filters) +def _store_updated_checkpoint(cursor, checkpoint_name, events): + for event in events: + yield event + cursor.replace(checkpoint_name, event["eventId"]) diff --git a/src/code42cli/cmds/util.py b/src/code42cli/cmds/util.py new file mode 100644 index 000000000..ce6ad557d --- /dev/null +++ b/src/code42cli/cmds/util.py @@ -0,0 +1,89 @@ +import itertools + +from py42.sdk.queries.alerts.filters import DateObserved +from py42.sdk.queries.fileevents.filters import EventTimestamp +from py42.sdk.queries.fileevents.filters import ExposureType +from py42.sdk.queries.fileevents.filters import InsertionTimestamp +from py42.sdk.queries.query_filter import FilterGroup +from py42.sdk.queries.query_filter import QueryFilterTimestampField + +from code42cli import errors +from code42cli.date_helper import verify_timestamp_order +from code42cli.logger import get_main_cli_logger +from code42cli.output_formats import OutputFormat + +logger = get_main_cli_logger() + + +def convert_to_or_query(filter_groups): + and_group = FilterGroup([], "AND") + or_group = FilterGroup([], "OR") + filters = itertools.chain.from_iterable([f.filter_list for f in filter_groups]) + for _filter in filters: + if _is_exempt_filter(_filter): + and_group.filter_list.append(_filter) + else: + or_group.filter_list.append(_filter) + if and_group.filter_list: + return [and_group, or_group] + else: + return [or_group] + + +def _is_exempt_filter(f): + # exclude timestamp filters by default from "OR" queries + # if other filters need to be exempt when building a query, append them to this list + # can either be a `QueryFilter` subclass, or a composed `FilterGroup` if more precision on + # is needed for which filters should be "AND"ed + or_query_exempt_filters = [ + InsertionTimestamp, + EventTimestamp, + DateObserved, + ExposureType.exists(), + ] + + for exempt in or_query_exempt_filters: + if isinstance(exempt, FilterGroup): + if f in exempt: + return True + else: + continue + elif f.term == exempt._term: + return True + return False + + +def try_get_default_header(include_all, default_header, output_format): + """Returns appropriate header based on include-all and output format. If returns None, + the CLI format option will figure out the header based on the data keys.""" + output_header = None if include_all else default_header + if output_format != OutputFormat.TABLE and include_all: + err_text = "--include-all only allowed for Table output format." + logger.log_error(err_text) + raise errors.Code42CLIError(err_text) + return output_header + + +def create_time_range_filter(filter_cls, begin_date=None, end_date=None): + """Creates a filter using the given filter class (must be a subclass of + :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) and date args. Returns + `None` if both begin_date and end_date args are `None`. + + Args: + filter_cls: The class of filter to create. (must be a subclass of + :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) + begin_date: The begin date for the range. + end_date: The end date for the range. + """ + if not issubclass(filter_cls, QueryFilterTimestampField): + raise Exception("filter_cls must be a subclass of QueryFilterTimestampField") + + if begin_date and end_date: + verify_timestamp_order(begin_date, end_date) + return filter_cls.in_range(begin_date, end_date) + + elif begin_date and not end_date: + return filter_cls.on_or_after(begin_date) + + elif end_date and not begin_date: + return filter_cls.on_or_before(end_date) diff --git a/src/code42cli/options.py b/src/code42cli/options.py index efa33331b..29691d94e 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -186,7 +186,11 @@ def end_option(term, **kwargs): def checkpoint_option(term, **kwargs): - defaults = dict(help=f"Only get {term} that were not previously retrieved.") + defaults = dict( + help=f"Use a checkpoint with the given name to only get {term} that were not previously retrieved." + f"If a checkpoint for {term} with the given name doesn't exist, it will be created on the first run." + "Subsequent CLI runs with this flag and the same name will use the stored checkpoint to modify the search query and then update the stored checkpoint" + ) defaults.update(kwargs) return click.option("-c", "--use-checkpoint", **defaults) diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index dc5027f43..a9cc7f99f 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -184,6 +184,9 @@ def __init__(self, output_format, header=None): if output_format == FileEventsOutputFormat.CEF: self._format_func = to_cef + def echo_formatted_generated_output(self, event): + pass + def to_cef(output): """Output is a single record""" diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 78547b85b..bbd9beb8f 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -49,13 +49,6 @@ def cli_logger(mocker): return mock -@pytest.fixture -def event_extractor_logger(mocker): - mock = mocker.patch("code42cli.logger.handlers.NoPrioritySysLogHandler") - mock.emit.return_value = mocker.MagicMock() - return mock - - @pytest.fixture def cli_state_with_user(sdk_with_user, cli_state): cli_state.sdk = sdk_with_user @@ -86,9 +79,8 @@ def get_filter_value_from_json(json, filter_index): return json_module.loads(str(json))["filters"][filter_index]["value"] -def filter_term_is_in_call_args(extractor, term): - arg_filters = extractor.extract.call_args[0] - for f in arg_filters: +def filter_term_is_in_call_args(filter_group, term): + for f in filter_group: if term in str(f): return True return False diff --git a/tests/cmds/search/test_cursor_store.py b/tests/cmds/search/test_cursor_store.py index 6e1fbcda2..09a4d65da 100644 --- a/tests/cmds/search/test_cursor_store.py +++ b/tests/cmds/search/test_cursor_store.py @@ -24,6 +24,12 @@ def mock_open(mocker): return mock +@pytest.fixture +def mock_empty_checkpoint(mocker): + mock = mocker.patch("builtins.open", mocker.mock_open(read_data="")) + return mock + + AUDIT_LOG_EVENT_HASH_1 = "bc8f70ff821cadcc3e717d534d14737d" AUDIT_LOG_EVENT_HASH_2 = "66ad12c0a0dba2b41520fb69aeefd84d" @@ -142,7 +148,7 @@ class TestFileEventCursorStore: def test_get_returns_expected_timestamp(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) checkpoint = store.get(CURSOR_NAME) - assert checkpoint == 123456789 + assert checkpoint == "123456789" def test_get_reads_expected_file(self, mock_open): store = FileEventCursorStore(PROFILE_NAME) diff --git a/tests/cmds/search/test_extraction.py b/tests/cmds/search/test_extraction.py deleted file mode 100644 index 9ca0b04d0..000000000 --- a/tests/cmds/search/test_extraction.py +++ /dev/null @@ -1,86 +0,0 @@ -import pytest -from c42eventextractor.extractors import BaseExtractor -from tests.conftest import create_mock_response - -from code42cli import errors -from code42cli.cmds.search.cursor_store import BaseCursorStore -from code42cli.cmds.search.extraction import create_handlers -from code42cli.cmds.search.extraction import create_send_to_handlers -from code42cli.cmds.search.extraction import try_get_default_header -from code42cli.output_formats import OutputFormat - -key = "events" - - -class TestQuery: - """""" - - pass - - -def search(*args, **kwargs): - pass - - -def test_try_get_default_header_raises_cli_error_when_using_include_all_with_none_table_format(): - with pytest.raises(errors.Code42CLIError) as err: - try_get_default_header(True, {}, OutputFormat.CSV) - - assert str(err.value) == "--include-all only allowed for Table output format." - - -def test_try_get_default_header_uses_default_header_when_not_include_all(): - default_header = {"default": "header"} - actual = try_get_default_header(False, default_header, OutputFormat.TABLE) - assert actual is default_header - - -def test_try_get_default_header_returns_none_when_is_table_and_told_to_include_all(): - default_header = {"default": "header"} - actual = try_get_default_header(True, default_header, OutputFormat.TABLE) - assert actual is None - - -def test_create_handlers_creates_handlers_that_pass_events_to_output_formatter( - mocker, sdk, -): - class TestExtractor(BaseExtractor): - def __init__(self, handlers, timestamp_filter): - timestamp_filter._term = "test_term" - super().__init__(key, search, handlers, timestamp_filter, TestQuery) - - def _get_timestamp_from_item(self, item): - pass - - formatter = mocker.MagicMock() - cursor_store = mocker.MagicMock(sepc=BaseCursorStore) - handlers = create_handlers( - sdk, TestExtractor, cursor_store, "chk-name", formatter, force_pager=False - ) - events = [{"property": "bar"}] - data = f'{{"{key}": [{{"property": "bar"}}]}}' - py42_response = create_mock_response(mocker, data=data) - handlers.handle_response(py42_response) - formatter.echo_formatted_list.assert_called_once_with(events, force_pager=False) - - -def test_send_to_handlers_creates_handlers_that_pass_events_to_logger( - mocker, sdk, event_extractor_logger -): - class TestExtractor(BaseExtractor): - def __init__(self, handlers, timestamp_filter): - timestamp_filter._term = "test_term" - super().__init__(key, search, handlers, timestamp_filter, TestQuery) - - def _get_timestamp_from_item(self, item): - pass - - cursor_store = mocker.MagicMock(sepc=BaseCursorStore) - handlers = create_send_to_handlers( - sdk, TestExtractor, cursor_store, "chk-name", event_extractor_logger - ) - events = [{"property": "bar"}] - data = f'{{"{key}": [{{"property": "bar"}}]}}' - py42_response = create_mock_response(mocker, data=data) - handlers.handle_response(py42_response) - event_extractor_logger.info.assert_called_once_with(events[0]) diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index c2ec8ab78..0a3ebdb4c 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -1,20 +1,16 @@ -import json import logging import py42.sdk.queries.alerts.filters as f import pytest -from c42eventextractor.extractors import AlertExtractor from py42.exceptions import Py42NotFoundError +from py42.sdk.queries.alerts.alert_query import AlertQuery from py42.sdk.queries.alerts.filters import AlertState from tests.cmds.conftest import filter_term_is_in_call_args -from tests.cmds.conftest import get_filter_value_from_json from tests.cmds.conftest import get_mark_for_search_and_send_to from tests.conftest import create_mock_response from tests.conftest import get_test_date_str -from code42cli import errors from code42cli import PRODUCT_NAME -from code42cli.cmds.search import extraction from code42cli.cmds.search.cursor_store import AlertCursorStore from code42cli.logger.enums import ServerProtocol from code42cli.main import cli @@ -23,7 +19,6 @@ BEGIN_TIMESTAMP = 1577858400.0 END_TIMESTAMP = 1580450400.0 CURSOR_TIMESTAMP = 1579500000.0 -ALERT_SUMMARY_LIST = [{"id": i} for i in range(20)] ALERT_DETAIL_RESULT = [ { "alerts": [ @@ -297,20 +292,13 @@ search_and_send_to_test = get_mark_for_search_and_send_to("alerts") -@pytest.fixture -def alert_extractor(mocker): - mock = mocker.patch(f"{PRODUCT_NAME}.cmds.alerts._get_alert_extractor") - mock.return_value = mocker.MagicMock(spec=AlertExtractor) - return mock.return_value - - @pytest.fixture def alert_cursor_with_checkpoint(mocker): mock = mocker.patch(f"{PRODUCT_NAME}.cmds.alerts._get_alert_cursor_store") mock_cursor = mocker.MagicMock(spec=AlertCursorStore) mock_cursor.get.return_value = CURSOR_TIMESTAMP mock.return_value = mock_cursor - mock.expected_timestamp = "2020-01-20T06:00:00+00:00" + mock.expected_datetime = "2020-01-20 06:00:00" return mock @@ -331,11 +319,6 @@ def begin_option(mocker): return mock -@pytest.fixture -def alert_extract_func(mocker): - return mocker.patch(f"{PRODUCT_NAME}.cmds.alerts._extract") - - @pytest.fixture def send_to_logger_factory(mocker): return mocker.patch("code42cli.cmds.search._try_get_logger_for_server") @@ -346,14 +329,152 @@ def full_alert_details_response(mocker): return create_mock_response(mocker, data=ALERT_DETAILS_FULL_RESPONSE) +@pytest.fixture +def mock_alert_search_response(mocker): + + data = { + "type$": "ALERT_DETAILS", + "tenantId": "1d71796f-af5b-4231-9d8e-df6434da4663", + "type": "FED_COMPOSITE", + "name": "File Upload Alert", + "description": "Alert on any file upload events", + "actor": "test.user@code42.com", + "actorId": "1018651385932568954", + "target": "N/A", + "severity": "MEDIUM", + "ruleId": "962a6a1c-54f6-4477-90bd-a08cc74cbf71", + "ruleSource": "Alerting", + "id": "c209fa6c-c3c7-4242-b6de-207c0ff13e38", + "createdAt": "2021-09-01T07:43:06.7831980Z", + "state": "OPEN", + "observations": [ + { + "type$": "OBSERVATION", + "id": "3af2494d-3981-46b5-a14b-6087edc48c5c", + "observedAt": "2021-09-01T07:15:00.0000000Z", + "type": "FedEndpointExfiltration", + "data": { + "type$": "OBSERVED_ENDPOINT_ACTIVITY", + "id": "3af2494d-3981-46b5-a14b-6087edc48c5c", + "sources": ["Endpoint"], + "exposureTypes": ["ApplicationRead"], + "exposureTypeIsSignificant": True, + "firstActivityAt": "2021-09-01T07:15:00.0000000Z", + "lastActivityAt": "2021-09-01T07:20:00.0000000Z", + "fileCount": 1, + "totalFileSize": 7842255, + "fileCategories": [ + { + "type$": "OBSERVED_FILE_CATEGORY", + "category": "Archive", + "fileCount": 1, + "totalFileSize": 7842255, + } + ], + "fileCategoryIsSignificant": False, + "files": [ + { + "type$": "OBSERVED_FILE", + "eventId": "0_1d71796f-af5b-4231-9d8e-df6434da4663_1019043250830767116_1022963810130583921_3_EPS", + "path": "C:/Users/qa/Downloads/", + "name": "TA-code42-insider-threats-add-on.tar.gz", + "category": "Archive", + "size": 7842255, + "riskSeverityInfo": { + "type$": "RISK_SEVERITY_INFO", + "score": 3, + "severity": "LOW", + "matchedRiskIndicators": [ + { + "type$": "RISK_INDICATOR", + "name": "Zip", + "weight": 3, + }, + { + "type$": "RISK_INDICATOR", + "name": "Other destination", + "weight": 0, + }, + ], + }, + "observedAt": "2021-09-01T07:19:18.5860000Z", + } + ], + "riskSeverityIsSignificant": False, + "riskSeveritySummary": [ + { + "type$": "RISK_SEVERITY_SUMMARY", + "severity": "LOW", + "numEvents": 1, + "summarizedRiskIndicators": [ + { + "type$": "SUMMARIZED_RISK_INDICATOR", + "name": "Zip", + "numEvents": 1, + }, + { + "type$": "SUMMARIZED_RISK_INDICATOR", + "name": "Other destination", + "numEvents": 1, + }, + ], + } + ], + "syncToServices": [], + "sendingIpAddresses": ["162.222.47.183"], + "appReadDetails": [ + { + "type$": "APP_READ_DETAILS", + "tabInfos": [ + { + "type$": "TAB_INFO", + "tabUrl": "http://127.0.0.1:8000/en-US/manager/appinstall/_upload", + "tabTitle": "Settings | Splunk - Google Chrome", + } + ], + "destinationCategory": "Uncategorized", + "destinationName": "Uncategorized", + "processName": "\\Device\\HarddiskVolume2\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + } + ], + "destinationIsSignificant": False, + }, + } + ], + } + + def response_gen(): + yield data + + return response_gen() + + +@pytest.fixture +def search_all_alerts_success(cli_state, mock_alert_search_response): + cli_state.sdk.alerts.get_all_alert_details.return_value = mock_alert_search_response + + +@search_and_send_to_test +def test_search_and_send_to_passes_query_object_when_searching_file_events( + runner, cli_state, command, search_all_alerts_success +): + runner.invoke( + cli, [*command, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + ) + + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert isinstance(query, AlertQuery) + + @search_and_send_to_test def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_expected_query( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): runner.invoke( cli, [*command, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state, ) - passed_filter_groups = alert_extractor.extract.call_args[0] + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + passed_filter_groups = query._filter_group_list expected_actor_filter = f.Actor.contains(ADVANCED_QUERY_VALUES["actor"]) expected_actor_filter.filter_clause = "OR" expected_timestamp_filter = f.DateObserved.in_range( @@ -375,16 +496,6 @@ def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_exp assert expected_rule_id_filter in passed_filter_groups -@search_and_send_to_test -def test_search_and_send_to_without_advanced_query_uses_only_the_extract_method( - cli_state, alert_extractor, runner, command -): - - runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) - assert alert_extractor.extract.call_count == 1 - assert alert_extractor.extract_advanced.call_count == 0 - - @advanced_query_incompat_test_params def test_search_with_advanced_query_and_incompatible_argument_errors( arg, cli_state, runner @@ -415,7 +526,7 @@ def test_send_to_with_advanced_query_and_incompatible_argument_errors( @search_and_send_to_test def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) @@ -423,18 +534,22 @@ def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( runner.invoke( cli, [*command, "--begin", begin_date, "--end", end_date], obj=cli_state, ) - filters = alert_extractor.extract.call_args[0][0] - actual_begin = get_filter_value_from_json(filters, filter_index=0) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + query_dict = dict(query) + + actual_begin = query_dict["groups"][0]["filters"][0]["value"] expected_begin = f"{begin_date}T00:00:00.000000Z" - actual_end = get_filter_value_from_json(filters, filter_index=1) + + actual_end = query_dict["groups"][0]["filters"][1]["value"] expected_end = f"{end_date}T23:59:59.999999Z" + assert actual_begin == expected_begin assert actual_end == expected_end @search_and_send_to_test def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) @@ -444,32 +559,36 @@ def test_search_when_given_begin_and_end_date_and_times_uses_expected_query( [*command, "--begin", f"{begin_date} {time}", "--end", f"{end_date} {time}"], obj=cli_state, ) - filters = alert_extractor.extract.call_args[0][0] - actual_begin = get_filter_value_from_json(filters, filter_index=0) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + query_dict = dict(query) + + actual_begin = query_dict["groups"][0]["filters"][0]["value"] expected_begin = f"{begin_date}T{time}.000000Z" - actual_end = get_filter_value_from_json(filters, filter_index=1) + + actual_end = query_dict["groups"][0]["filters"][1]["value"] expected_end = f"{end_date}T{time}.000000Z" + assert actual_begin == expected_begin assert actual_end == expected_end @search_and_send_to_test def test_search_when_given_begin_date_and_time_without_seconds_uses_expected_query( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): date = get_test_date_str(days_ago=89) time = "15:33" runner.invoke(cli, [*command, "--begin", f"{date} {time}"], obj=cli_state) - actual = get_filter_value_from_json( - alert_extractor.extract.call_args[0][0], filter_index=0 - ) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + query_dict = dict(query) + actual = query_dict["groups"][0]["filters"][0]["value"] expected = f"{date}T{time}:00.000000Z" assert actual == expected @search_and_send_to_test def test_search_and_send_to_when_given_end_date_and_time_uses_expected_query( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): begin_date = get_test_date_str(days_ago=10) end_date = get_test_date_str(days_ago=1) @@ -479,9 +598,9 @@ def test_search_and_send_to_when_given_end_date_and_time_uses_expected_query( [*command, "--begin", begin_date, "--end", f"{end_date} {time}"], obj=cli_state, ) - actual = get_filter_value_from_json( - alert_extractor.extract.call_args[0][0], filter_index=1 - ) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + query_dict = dict(query) + actual = query_dict["groups"][0]["filters"][1]["value"] expected = f"{end_date}T{time}:00.000000Z" assert actual == expected @@ -496,31 +615,18 @@ def test_search_and_send_to_when_given_begin_date_more_than_ninety_days_back_err assert result.exit_code == 2 -@search_and_send_to_test -def test_search_and_send_to_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - cli_state, alert_cursor_with_checkpoint, alert_extractor, runner, command -): - begin_date = get_test_date_str(days_ago=91) + " 12:51:00" - runner.invoke( - cli, - [*command, "--begin", begin_date, "--use-checkpoint", "test"], - obj=cli_state, - ) - assert not filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) - - @search_and_send_to_test def test_search_and_send_to_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): begin_date = get_test_date_str(days_ago=1) runner.invoke(cli, [*command, "--begin", begin_date], obj=cli_state) - actual_ts = get_filter_value_from_json( - alert_extractor.extract.call_args[0][0], filter_index=0 - ) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + query_dict = dict(query) + actual_ts = query_dict["groups"][0]["filters"][0]["value"] expected_ts = f"{begin_date}T00:00:00.000000Z" assert actual_ts == expected_ts - assert filter_term_is_in_call_args(alert_extractor, f.DateObserved._term) + assert filter_term_is_in_call_args(query, f.DateObserved._term) @search_and_send_to_test @@ -537,16 +643,26 @@ def test_search_and_send_to_when_end_date_is_before_begin_date_causes_exit( @search_and_send_to_test -def test_search_and_send_to_with_only_begin_calls_extract_with_expected_filters( - cli_state, alert_extractor, begin_option, runner, command +def test_search_and_send_to_with_only_begin_calls_search_all_alerts_with_expected_filters( + cli_state, begin_option, runner, command, search_all_alerts_success ): res = runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + query_dict = dict(query) + expected_filter_groups = [ + { + "filterClause": "AND", + "filters": [ + { + "operator": "ON_OR_AFTER", + "term": "createdAt", + "value": begin_option.expected_timestamp, + } + ], + } + ] assert res.exit_code == 0 - assert ( - str(alert_extractor.extract.call_args[0][0]) - == '{"filterClause":"AND", "filters":[{"operator":"ON_OR_AFTER", "term":"createdAt", ' - f'"value":"{begin_option.expected_timestamp}"}}]}}' - ) + assert query_dict["groups"] == expected_filter_groups @search_and_send_to_test @@ -562,78 +678,81 @@ def test_search_and_send_to_with_use_checkpoint_and_without_begin_and_without_st @search_and_send_to_test -def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( +def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_search_all_alerts_with_begin_date( cli_state, - alert_extractor, begin_option, alert_cursor_without_checkpoint, runner, command, + search_all_alerts_success, ): res = runner.invoke( cli, [*command, "--use-checkpoint", "test", "--begin", "1d"], obj=cli_state, ) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + query_dict = dict(query) + actual_begin = query_dict["groups"][0]["filters"][0]["value"] + assert res.exit_code == 0 - assert len(alert_extractor.extract.call_args[0]) == 1 - assert begin_option.expected_timestamp in str( - alert_extractor.extract.call_args[0][0] - ) + assert len(query._filter_group_list) == 1 + assert begin_option.expected_timestamp == actual_begin @search_and_send_to_test -def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( - cli_state, alert_extractor, alert_cursor_with_checkpoint, runner, command +def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_search_all_alerts_with_checkpoint_and_ignores_begin_arg( + cli_state, alert_cursor_with_checkpoint, runner, command, search_all_alerts_success ): result = runner.invoke( cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, ) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] assert result.exit_code == 0 - assert alert_extractor.extract.call_count == 1 + assert len(query._filter_group_list) == 1 assert ( - f"checkpoint of {alert_cursor_with_checkpoint.expected_timestamp} exists" + f"checkpoint of {alert_cursor_with_checkpoint.expected_datetime} exists" in result.output ) @search_and_send_to_test def test_search_and_send_to_when_given_actor_is_uses_username_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): actor_name = "test.testerson" runner.invoke( cli, [*command, "--begin", "1h", "--actor", actor_name], obj=cli_state ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.Actor.is_in([actor_name])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.Actor.is_in([actor_name]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_exclude_actor_uses_actor_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): actor_name = "test.testerson" runner.invoke( cli, [*command, "--begin", "1h", "--exclude-actor", actor_name], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.Actor.not_in([actor_name])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.Actor.not_in([actor_name]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_rule_name_uses_rule_name_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): rule_name = "departing employee" runner.invoke( cli, [*command, "--begin", "1h", "--rule-name", rule_name], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleName.is_in([rule_name])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.RuleName.is_in([rule_name]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_exclude_rule_name_uses_rule_name_not_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): rule_name = "departing employee" runner.invoke( @@ -641,25 +760,25 @@ def test_search_and_send_to_when_given_exclude_rule_name_uses_rule_name_not_filt [*command, "--begin", "1h", "--exclude-rule-name", rule_name], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleName.not_in([rule_name])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.RuleName.not_in([rule_name]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_rule_type_uses_rule_name_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): rule_type = "FedEndpointExfiltration" runner.invoke( cli, [*command, "--begin", "1h", "--rule-type", rule_type], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleType.is_in([rule_type])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.RuleType.is_in([rule_type]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_exclude_rule_type_uses_rule_name_not_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): rule_type = "FedEndpointExfiltration" runner.invoke( @@ -667,47 +786,47 @@ def test_search_and_send_to_when_given_exclude_rule_type_uses_rule_name_not_filt [*command, "--begin", "1h", "--exclude-rule-type", rule_type], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleType.not_in([rule_type])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.RuleType.not_in([rule_type]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_rule_id_uses_rule_name_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): rule_id = "departing employee" runner.invoke(cli, [*command, "--begin", "1h", "--rule-id", rule_id], obj=cli_state) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleId.is_in([rule_id])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.RuleId.is_in([rule_id]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_exclude_rule_id_uses_rule_name_not_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): rule_id = "departing employee" runner.invoke( cli, [*command, "--begin", "1h", "--exclude-rule-id", rule_id], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.RuleId.not_in([rule_id])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.RuleId.not_in([rule_id]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_description_uses_description_filter( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): description = "test description" runner.invoke( cli, [*command, "--begin", "1h", "--description", description], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.Description.contains(description)) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.Description.contains(description) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_multiple_search_args_uses_expected_filters( - cli_state, alert_extractor, runner, command + cli_state, runner, command, search_all_alerts_success ): actor = "test.testerson@example.com" exclude_actor = "flag.flagerson@example.com" @@ -728,15 +847,15 @@ def test_search_and_send_to_when_given_multiple_search_args_uses_expected_filter ], obj=cli_state, ) - filter_strings = [str(arg) for arg in alert_extractor.extract.call_args[0]] - assert str(f.Actor.is_in([actor])) in filter_strings - assert str(f.Actor.not_in([exclude_actor])) in filter_strings - assert str(f.RuleName.is_in([rule_name])) in filter_strings + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + assert f.Actor.is_in([actor]) in query._filter_group_list + assert f.Actor.not_in([exclude_actor]) in query._filter_group_list + assert f.RuleName.is_in([rule_name]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_with_or_query_flag_produces_expected_query( - runner, cli_state, command + runner, cli_state, command, search_all_alerts_success ): begin_date = get_test_date_str(days_ago=10) test_actor = "test@example.com" @@ -782,27 +901,27 @@ def test_search_and_send_to_with_or_query_flag_produces_expected_query( }, ], "pgNum": 0, - "pgSize": 500, + "pgSize": 25, "srtDirection": "asc", "srtKey": "CreatedAt", } - actual_query = json.loads(str(cli_state.sdk.alerts.search.call_args[0][0])) + query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] + actual_query = dict(query) assert actual_query == expected_query @search_and_send_to_test -def test_search_and_send_to_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( +def test_search_and_send_to_handles_error_expected_message_logged_and_printed( runner, cli_state, caplog, command ): - errors.ERRORED = False exception_msg = "Test Exception" - cli_state.sdk.alerts.search.side_effect = Exception(exception_msg) + expected_msg = "Unknown problem occurred" + cli_state.sdk.alerts.get_all_alert_details.side_effect = Exception(exception_msg) with caplog.at_level(logging.ERROR): result = runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) assert "Error:" in result.output - assert exception_msg in result.output + assert expected_msg in result.output assert exception_msg in caplog.text - assert errors.ERRORED @pytest.mark.parametrize( @@ -937,20 +1056,6 @@ def test_send_to_when_given_certs_with_non_tls_protocol_fails_expectedly( assert "'--certs' can only be used with '--protocol TLS-TCP'" in res.output -def test_get_alert_details_batches_results_according_to_batch_size(sdk): - extraction._ALERT_DETAIL_BATCH_SIZE = 2 - sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT - extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) - assert sdk.alerts.get_details.call_count == 10 - - -def test_get_alert_details_sorts_results_by_date(sdk): - extraction._ALERT_DETAIL_BATCH_SIZE = 2 - sdk.alerts.get_details.side_effect = ALERT_DETAIL_RESULT - results = extraction._get_alert_details(sdk, ALERT_SUMMARY_LIST) - assert results == SORTED_ALERT_DETAILS - - def test_show_outputs_expected_headers(cli_state, runner, full_alert_details_response): cli_state.sdk.alerts.get_details.return_value = full_alert_details_response result = runner.invoke(cli, ["alerts", "show", "TEST-ALERT-ID"], obj=cli_state) diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 1e5adf137..278885936 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -3,17 +3,15 @@ import py42.sdk.queries.fileevents.filters as f import pytest -from c42eventextractor.extractors import FileEventExtractor from py42.sdk.queries.fileevents.file_event_query import FileEventQuery from py42.sdk.queries.fileevents.filters import RiskIndicator from py42.sdk.queries.fileevents.filters import RiskSeverity from py42.sdk.queries.fileevents.filters.file_filter import FileCategory from tests.cmds.conftest import filter_term_is_in_call_args -from tests.cmds.conftest import get_filter_value_from_json from tests.cmds.conftest import get_mark_for_search_and_send_to +from tests.conftest import create_mock_response from tests.conftest import get_test_date_str -from code42cli import errors from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.logger.enums import ServerProtocol from code42cli.main import cli @@ -135,23 +133,68 @@ ("--risk-severity", "LOW"), ], ) + +TEST_FILE_EVENT_TIMESTAMP_1 = "2020-01-01T12:00:00.000Z" +TEST_FILE_EVENT_TIMESTAMP_2 = "2020-02-01T12:01:00.000111Z" +TEST_FILE_EVENT_ID_1 = "0_test1" +TEST_FILE_EVENT_ID_2 = "0_test2" +TEST_EVENTS = [ + { + "eventId": TEST_FILE_EVENT_ID_1, + "eventType": "READ_BY_APP", + "eventTimestamp": TEST_FILE_EVENT_TIMESTAMP_1, + "insertionTimestamp": TEST_FILE_EVENT_TIMESTAMP_1, + "fileName": "test.txt", + "fileType": "FILE", + "fileCategory": "Document", + "destinationCategory": "Cloud Storage", + "destinationName": "Google Drive", + "riskScore": 5, + "riskSeverity": "MODERATE", + "riskIndicators": [ + {"name": "Google Drive upload", "weight": 5}, + {"name": "Document", "weight": 0}, + ], + }, + { + "eventId": TEST_FILE_EVENT_ID_2, + "eventType": "READ_BY_APP", + "eventTimestamp": TEST_FILE_EVENT_TIMESTAMP_2, + "insertionTimestamp": TEST_FILE_EVENT_TIMESTAMP_2, + "fileName": "test2.txt", + "fileType": "FILE", + "fileCategory": "Document", + "destinationCategory": "Cloud Storage", + "destinationName": "Google Drive", + "riskScore": 5, + "riskSeverity": "MODERATE", + "riskIndicators": [ + {"name": "Google Drive upload", "weight": 5}, + {"name": "Document", "weight": 0}, + ], + }, +] + search_and_send_to_test = get_mark_for_search_and_send_to("security-data") @pytest.fixture -def file_event_extractor(mocker): - mock = mocker.patch("code42cli.cmds.securitydata._get_file_event_extractor") - mock.return_value = mocker.MagicMock(spec=FileEventExtractor) - return mock.return_value +def file_event_cursor_with_timestamp_checkpoint(mocker): + mock = mocker.patch("code42cli.cmds.securitydata._get_file_event_cursor_store") + mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) + mock_cursor.get.return_value = CURSOR_TIMESTAMP + mock.return_value = mock_cursor + mock.expected_timestamp = "2020-01-20T06:00:00.000Z" + return mock @pytest.fixture -def file_event_cursor_with_checkpoint(mocker): +def file_event_cursor_with_eventid_checkpoint(mocker): mock = mocker.patch("code42cli.cmds.securitydata._get_file_event_cursor_store") mock_cursor = mocker.MagicMock(spec=FileEventCursorStore) - mock_cursor.get.return_value = CURSOR_TIMESTAMP + mock_cursor.get.return_value = TEST_FILE_EVENT_ID_2 mock.return_value = mock_cursor - mock.expected_timestamp = "2020-01-20T06:00:00+00:00" + mock.expected_eventid = "0_test2" return mock @@ -177,14 +220,46 @@ def send_to_logger_factory(mocker): return mocker.patch("code42cli.cmds.search._try_get_logger_for_server") +@pytest.fixture +def mock_file_event_response(mocker): + data = json.dumps( + {"totalCount": 2, "fileEvents": TEST_EVENTS, "nextPgToken": "", "problems": ""} + ) + + response = create_mock_response(mocker, data=data) + + return response + + +@pytest.fixture +def search_all_file_events_success(cli_state, mock_file_event_response): + cli_state.sdk.securitydata.search_all_file_events.return_value = ( + mock_file_event_response + ) + + +@search_and_send_to_test +def test_search_and_send_to_passes_query_object_when_searching_file_events( + runner, cli_state, command, search_all_file_events_success +): + runner.invoke( + cli, [*command, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state + ) + + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + assert isinstance(query, FileEventQuery) + + @search_and_send_to_test def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_expected_query( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): runner.invoke( cli, [*command, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state ) - passed_filter_groups = file_event_extractor.extract.call_args[0] + + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + passed_filter_groups = query._filter_group_list expected_event_filter = f.EventTimestamp.within_the_last( ADVANCED_QUERY_VALUES["within_last_value"] ) @@ -195,6 +270,7 @@ def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_exp [ADVANCED_QUERY_VALUES["event_type"]] ) expected_event_type_filter.filter_clause = "OR" + assert expected_event_filter in passed_filter_groups assert expected_hostname_filter in passed_filter_groups assert expected_event_type_filter in passed_filter_groups @@ -202,14 +278,17 @@ def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_exp @search_and_send_to_test def test_search_and_send_to_when_advanced_query_passed_as_filename_builds_expected_query( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): + with runner.isolated_filesystem(): with open("query.json", "w") as jsonfile: jsonfile.write(ADVANCED_QUERY_JSON) runner.invoke(cli, [*command, "--advanced-query", "@query.json"], obj=cli_state) - passed_filter_groups = file_event_extractor.extract.call_args[0] + + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + passed_filter_groups = query._filter_group_list expected_event_filter = f.EventTimestamp.within_the_last( ADVANCED_QUERY_VALUES["within_last_value"] ) @@ -344,10 +423,11 @@ def test_send_to_when_given_certs_with_non_tls_protocol_fails_expectedly( @search_and_send_to_test def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) + runner.invoke( cli, [ @@ -359,67 +439,80 @@ def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( ], obj=cli_state, ) - filters = file_event_extractor.extract.call_args[0][1] - actual_begin = get_filter_value_from_json(filters, filter_index=0) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + query_dict = dict(query) + + actual_begin = query_dict["groups"][1]["filters"][0]["value"] expected_begin = f"{begin_date}T00:00:00.000Z" - actual_end = get_filter_value_from_json(filters, filter_index=1) + + actual_end = query_dict["groups"][1]["filters"][1]["value"] expected_end = f"{end_date}T23:59:59.999Z" + assert actual_begin == expected_begin assert actual_end == expected_end @search_and_send_to_test def test_search_and_send_to_when_given_begin_and_end_date_and_time_uses_expected_query( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): begin_date = get_test_date_str(days_ago=89) end_date = get_test_date_str(days_ago=1) time = "15:33:02" + runner.invoke( cli, [*command, "--begin", f"{begin_date} {time}", "--end", f"{end_date} {time}"], obj=cli_state, ) - filters = file_event_extractor.extract.call_args[0][1] - actual_begin = get_filter_value_from_json(filters, filter_index=0) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + query_dict = dict(query) + + actual_begin = query_dict["groups"][1]["filters"][0]["value"] expected_begin = f"{begin_date}T{time}.000Z" - actual_end = get_filter_value_from_json(filters, filter_index=1) + + actual_end = query_dict["groups"][1]["filters"][1]["value"] expected_end = f"{end_date}T{time}.000Z" + assert actual_begin == expected_begin assert actual_end == expected_end @search_and_send_to_test def test_search_and_send_to_when_given_begin_date_and_time_without_seconds_uses_expected_query( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): date = get_test_date_str(days_ago=89) time = "15:33" + runner.invoke( cli, [*command, "--begin", f"{date} {time}"], obj=cli_state, ) - actual = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][1], filter_index=0 - ) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + query_dict = dict(query) + + actual = query_dict["groups"][1]["filters"][0]["value"] expected = f"{date}T{time}:00.000Z" assert actual == expected @search_and_send_to_test def test_search_and_send_to_when_given_end_date_and_time_uses_expected_query( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): begin_date = get_test_date_str(days_ago=10) end_date = get_test_date_str(days_ago=1) time = "15:33" + runner.invoke( cli, [*command, "--begin", begin_date, "--end", f"{end_date} {time}"], obj=cli_state, ) - actual = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][1], filter_index=1 - ) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + query_dict = dict(query) + + actual = query_dict["groups"][1]["filters"][1]["value"] expected = f"{end_date}T{time}:00.000Z" assert actual == expected @@ -439,31 +532,37 @@ def test_search_send_to_when_given_begin_date_more_than_ninety_days_back_errors( @search_and_send_to_test def test_search_and_send_to_when_given_begin_date_past_90_days_and_use_checkpoint_and_a_stored_cursor_exists_and_not_given_end_date_does_not_use_any_event_timestamp_filter( - runner, cli_state, file_event_cursor_with_checkpoint, file_event_extractor, command + runner, + cli_state, + file_event_cursor_with_eventid_checkpoint, + command, + search_all_file_events_success, ): begin_date = get_test_date_str(days_ago=91) + " 12:51:00" + runner.invoke( cli, [*command, "--begin", begin_date, "--use-checkpoint", "test"], obj=cli_state, ) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] assert not filter_term_is_in_call_args( - file_event_extractor, f.InsertionTimestamp._term + query._filter_group_list, f.InsertionTimestamp._term ) @search_and_send_to_test def test_search_and_send_to_when_given_begin_date_and_not_use_checkpoint_and_cursor_exists_uses_begin_date( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): begin_date = get_test_date_str(days_ago=1) runner.invoke(cli, [*command, "--begin", begin_date], obj=cli_state) - actual_ts = get_filter_value_from_json( - file_event_extractor.extract.call_args[0][1], filter_index=0 - ) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + query_dict = dict(query) + actual_ts = query_dict["groups"][1]["filters"][0]["value"] expected_ts = f"{begin_date}T00:00:00.000Z" assert actual_ts == expected_ts - assert filter_term_is_in_call_args(file_event_extractor, f.EventTimestamp._term) + assert filter_term_is_in_call_args(query._filter_group_list, f.EventTimestamp._term) @search_and_send_to_test @@ -480,16 +579,30 @@ def test_search_and_send_to_when_end_date_is_before_begin_date_causes_exit( @search_and_send_to_test -def test_search_and_send_to_with_only_begin_calls_extract_with_expected_args( - runner, cli_state, file_event_extractor, begin_option, command +def test_search_and_send_to_with_only_begin_calls_search_all_file_events_with_expected_args( + runner, cli_state, begin_option, command, search_all_file_events_success ): result = runner.invoke(cli, [*command, "--begin", "1h"], obj=cli_state) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + query_dict = dict(query) + expected_filter_groups = [ + { + "filterClause": "AND", + "filters": [{"operator": "EXISTS", "term": "exposure", "value": None}], + }, + { + "filterClause": "AND", + "filters": [ + { + "operator": "ON_OR_AFTER", + "term": "eventTimestamp", + "value": begin_option.expected_timestamp, + } + ], + }, + ] assert result.exit_code == 0 - assert ( - str(file_event_extractor.extract.call_args[0][1]) - == f'{{"filterClause":"AND", "filters":[{{"operator":"ON_OR_AFTER", "term":"eventTimestamp", ' - f'"value":"{begin_option.expected_timestamp}"}}]}}' - ) + assert query_dict["groups"] == expected_filter_groups @search_and_send_to_test @@ -505,35 +618,64 @@ def test_search_and_send_to_with_use_checkpoint_and_without_begin_and_without_ch @search_and_send_to_test -def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_extract_with_begin_date( +def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_checkpoint_calls_search_all_file_events_with_begin_date( runner, cli_state, - file_event_extractor, begin_option, file_event_cursor_without_checkpoint, command, + search_all_file_events_success, +): + result = runner.invoke( + cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, + ) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + query_dict = dict(query) + actual_begin = query_dict["groups"][1]["filters"][0]["value"] + + assert result.exit_code == 0 + assert len(query._filter_group_list) == 2 + assert begin_option.expected_timestamp == actual_begin + + +@search_and_send_to_test +def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_as_timestamp_calls_search_all_file_events_with_checkpoint_timestamp_and_ignores_begin_arg( + runner, + cli_state, + file_event_cursor_with_timestamp_checkpoint, + command, + search_all_file_events_success, ): result = runner.invoke( cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, ) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + query_dict = dict(query) + actual_query_timestamp = query_dict["groups"][1]["filters"][0]["value"] assert result.exit_code == 0 - assert len(file_event_extractor.extract.call_args[0]) == 2 - assert begin_option.expected_timestamp in str( - file_event_extractor.extract.call_args[0][1] + assert len(query._filter_group_list) == 2 + assert ( + file_event_cursor_with_timestamp_checkpoint.expected_timestamp + == actual_query_timestamp ) @search_and_send_to_test -def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_checkpoint_calls_extract_with_checkpoint_and_ignores_begin_arg( - runner, cli_state, file_event_extractor, file_event_cursor_with_checkpoint, command, +def test_search_and_send_to_with_use_checkpoint_and_with_stored_checkpoint_as_eventid_calls_search_all_file_events_with_checkpoint_and_ignores_begin_arg( + runner, + cli_state, + file_event_cursor_with_eventid_checkpoint, + command, + search_all_file_events_success, ): result = runner.invoke( cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, ) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] assert result.exit_code == 0 - assert len(file_event_extractor.extract.call_args[0]) == 1 + assert len(query._filter_group_list) == 1 assert ( - f"checkpoint of {file_event_cursor_with_checkpoint.expected_timestamp} exists" + f"checkpoint of {file_event_cursor_with_eventid_checkpoint.expected_eventid} exists" in result.output ) @@ -551,102 +693,114 @@ def test_search_and_send_to_when_given_invalid_exposure_type_causes_exit( @search_and_send_to_test def test_search_and_send_to_when_given_username_uses_username_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): c42_username = "test@example.com" command = [*command, "--begin", "1h", "--c42-username", c42_username] + runner.invoke( cli, [*command], obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.DeviceUsername.is_in([c42_username])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + + filter_obj = f.DeviceUsername.is_in([c42_username]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_actor_is_uses_username_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): actor_name = "test.testerson" command = [*command, "--begin", "1h", "--actor", actor_name] + runner.invoke( cli, [*command], obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.Actor.is_in([actor_name])) in filter_strings + + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.Actor.is_in([actor_name]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_md5_uses_md5_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): md5 = "abcd12345" command = [*command, "--begin", "1h", "--md5", md5] runner.invoke(cli, [*command], obj=cli_state) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.MD5.is_in([md5])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.MD5.is_in([md5]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_sha256_uses_sha256_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): sha_256 = "abcd12345" command = [*command, "--begin", "1h", "--sha256", sha_256] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.SHA256.is_in([sha_256])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.SHA256.is_in([sha_256]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_source_uses_source_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): source = "Gmail" command = [*command, "--begin", "1h", "--source", source] runner.invoke(cli, command, obj=cli_state) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.Source.is_in([source])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.Source.is_in([source]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_file_name_uses_file_name_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): filename = "test.txt" command = [*command, "--begin", "1h", "--file-name", filename] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.FileName.is_in([filename])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.FileName.is_in([filename]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_file_path_uses_file_path_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): filepath = "C:\\Program Files" command = [*command, "--begin", "1h", "--file-path", filepath] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.FilePath.is_in([filepath])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.FilePath.is_in([filepath]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_file_category_uses_file_category_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): file_category = FileCategory.IMAGE command = [*command, "--begin", "1h", "--file-category", file_category] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.FileCategory.is_in([file_category])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.FileCategory.is_in([file_category]) + assert filter_obj in query._filter_group_list @pytest.mark.parametrize( @@ -669,7 +823,7 @@ def test_search_and_send_to_when_given_file_category_uses_file_category_filter( ], ) def test_all_caps_file_category_choices_convert_to_filecategory_constant( - runner, cli_state, file_event_extractor, category_choice + runner, cli_state, category_choice, search_all_file_events_success ): ALL_CAPS_VALUE, camelCaseValue = category_choice command = [ @@ -683,74 +837,80 @@ def test_all_caps_file_category_choices_convert_to_filecategory_constant( runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.FileCategory.is_in([camelCaseValue])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.FileCategory.is_in([camelCaseValue]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_process_owner_uses_process_owner_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): process_owner = "root" command = [*command, "-b", "1h", "--process-owner", process_owner] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.ProcessOwner.is_in([process_owner])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.ProcessOwner.is_in([process_owner]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_tab_url_uses_process_tab_url_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): tab_url = "https://example.com" command = [*command, "--begin", "1h", "--tab-url", tab_url] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.TabURL.is_in([tab_url])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.TabURL.is_in([tab_url]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_exposure_types_uses_exposure_type_is_in_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): exposure_type = "SharedViaLink" command = [*command, "--begin", "1h", "--type", exposure_type] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.ExposureType.is_in([exposure_type])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.ExposureType.is_in([exposure_type]) + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_include_non_exposure_does_not_include_exposure_type_exists( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): runner.invoke( cli, [*command, "--begin", "1h", "--include-non-exposure"], obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.ExposureType.exists()) not in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.ExposureType.exists() + assert filter_obj not in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_not_given_include_non_exposure_includes_exposure_type_exists( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): runner.invoke( cli, [*command, "--begin", "1h"], obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.ExposureType.exists()) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + filter_obj = f.ExposureType.exists() + assert filter_obj in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_multiple_search_args_uses_expected_filters( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): process_owner = "root" c42_username = "test@example.com" @@ -770,15 +930,15 @@ def test_search_and_send_to_when_given_multiple_search_args_uses_expected_filter ], obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.ProcessOwner.is_in([process_owner])) in filter_strings - assert str(f.FileName.is_in([filename])) in filter_strings - assert str(f.DeviceUsername.is_in([c42_username])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + assert f.ProcessOwner.is_in([process_owner]) in query._filter_group_list + assert f.FileName.is_in([filename]) in query._filter_group_list + assert f.DeviceUsername.is_in([c42_username]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_include_non_exposure_and_exposure_types_causes_exit( - runner, cli_state, file_event_extractor, command + runner, cli_state, command ): result = runner.invoke( cli, @@ -797,15 +957,15 @@ def test_search_and_send_to_when_given_include_non_exposure_and_exposure_types_c @search_and_send_to_test def test_search_and_send_to_when_given_risk_indicator_uses_risk_indicator_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): risk_indicator = RiskIndicator.MessagingServiceUploads.SLACK command = [*command, "--begin", "1h", "--risk-indicator", risk_indicator] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.RiskIndicator.is_in([risk_indicator])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + assert f.RiskIndicator.is_in([risk_indicator]) in query._filter_group_list @pytest.mark.parametrize( @@ -892,7 +1052,7 @@ def test_search_and_send_to_when_given_risk_indicator_uses_risk_indicator_filter ], ) def test_all_caps_risk_indicator_choices_convert_to_risk_indicator_string( - runner, cli_state, file_event_extractor, indicator_choice + runner, cli_state, indicator_choice, search_all_file_events_success ): ALL_CAPS_VALUE, string_value = indicator_choice command = [ @@ -906,41 +1066,42 @@ def test_all_caps_risk_indicator_choices_convert_to_risk_indicator_string( runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.RiskIndicator.is_in([string_value])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + assert f.RiskIndicator.is_in([string_value]) in query._filter_group_list @search_and_send_to_test def test_search_and_send_to_when_given_risk_severity_uses_risk_severity_filter( - runner, cli_state, file_event_extractor, command + runner, cli_state, command, search_all_file_events_success ): risk_severity = RiskSeverity.LOW command = [*command, "--begin", "1h", "--risk-severity", risk_severity] runner.invoke( cli, command, obj=cli_state, ) - filter_strings = [str(arg) for arg in file_event_extractor.extract.call_args[0]] - assert str(f.RiskSeverity.is_in([risk_severity])) in filter_strings + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + assert f.RiskSeverity.is_in([risk_severity]) in query._filter_group_list @search_and_send_to_test -def test_search_and_send_to_when_extraction_handles_error_expected_message_logged_and_printed_and_global_errored_flag_set( +def test_search_and_send_to_handles_error_expected_message_logged_and_printed( runner, cli_state, caplog, command ): - errors.ERRORED = False exception_msg = "Test Exception" - cli_state.sdk.securitydata.search_file_events.side_effect = Exception(exception_msg) + expected_msg = "Unknown problem occurred" + cli_state.sdk.securitydata.search_all_file_events.side_effect = Exception( + exception_msg + ) with caplog.at_level(logging.ERROR): result = runner.invoke(cli, [*command, "--begin", "1d"], obj=cli_state) assert "Error:" in result.output - assert exception_msg in result.output + assert expected_msg in result.output assert exception_msg in caplog.text - assert errors.ERRORED @search_and_send_to_test def test_search_and_send_to_with_or_query_flag_produces_expected_query( - runner, cli_state, command + runner, cli_state, command, search_all_file_events_success ): begin_date = get_test_date_str(days_ago=10) test_username = "test@example.com" @@ -990,14 +1151,13 @@ def test_search_and_send_to_with_or_query_flag_produces_expected_query( "srtDir": "asc", "srtKey": "insertionTimestamp", } - actual_query = json.loads( - str(cli_state.sdk.securitydata.search_file_events.call_args[0][0]) - ) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + actual_query = dict(query) assert actual_query == expected_query -def test_saved_search_calls_extractor_extract_and_saved_search_execute( - runner, cli_state, file_event_extractor +def test_saved_search_calls_search_all_file_events_and_saved_search_execute( + runner, cli_state, search_all_file_events_success ): search_query = { "groupClause": "AND", @@ -1028,20 +1188,23 @@ def test_saved_search_calls_extractor_extract_and_saved_search_execute( "srtDir": "asc", "srtKey": "eventId", } - query = FileEventQuery.from_dict(search_query) - cli_state.sdk.securitydata.savedsearches.get_query.return_value = query + saved_search_query = FileEventQuery.from_dict(search_query) + cli_state.sdk.securitydata.savedsearches.get_query.return_value = saved_search_query runner.invoke( cli, ["security-data", "search", "--saved-search", "test_id"], obj=cli_state ) - assert file_event_extractor.extract.call_count == 1 - assert str(file_event_extractor.extract.call_args[0][0]) in str(query) - assert str(file_event_extractor.extract.call_args[0][1]) in str(query) + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + assert cli_state.sdk.securitydata.search_all_file_events.call_count == 1 + assert query._filter_group_list[0] in saved_search_query._filter_group_list + assert query._filter_group_list[1] in saved_search_query._filter_group_list @pytest.mark.parametrize( "protocol", (ServerProtocol.TLS_TCP, ServerProtocol.TLS_TCP, ServerProtocol.UDP) ) -def test_send_to_allows_protocol_arg(cli_state, runner, protocol): +def test_send_to_allows_protocol_arg( + cli_state, runner, protocol, search_all_file_events_success +): res = runner.invoke( cli, [ @@ -1058,7 +1221,9 @@ def test_send_to_allows_protocol_arg(cli_state, runner, protocol): assert res.exit_code == 0 -def test_send_to_fails_when_given_unknown_protocol(cli_state, runner): +def test_send_to_fails_when_given_unknown_protocol( + cli_state, runner, search_all_file_events_success +): res = runner.invoke( cli, ["security-data", "send-to", "0.0.0.0", "--begin", "1d", "--protocol", "ATM"], @@ -1068,7 +1233,7 @@ def test_send_to_fails_when_given_unknown_protocol(cli_state, runner): def test_send_to_certs_and_ignore_cert_validation_args_are_incompatible( - cli_state, runner + cli_state, runner, search_all_file_events_success ): res = runner.invoke( cli, @@ -1089,7 +1254,9 @@ def test_send_to_certs_and_ignore_cert_validation_args_are_incompatible( assert "Error: --ignore-cert-validation can't be used with: --certs" in res.output -def test_send_to_creates_expected_logger(cli_state, runner, send_to_logger_factory): +def test_send_to_creates_expected_logger( + cli_state, runner, send_to_logger_factory, search_all_file_events_success +): runner.invoke( cli, [ @@ -1111,7 +1278,7 @@ def test_send_to_creates_expected_logger(cli_state, runner, send_to_logger_facto def test_send_to_when_given_ignore_cert_validation_uses_certs_equal_to_ignore_str( - cli_state, runner, send_to_logger_factory + cli_state, runner, send_to_logger_factory, search_all_file_events_success ): runner.invoke( cli, diff --git a/tests/cmds/test_trustedactivities.py b/tests/cmds/test_trustedactivities.py index 31ff9cab6..ff96e80f2 100644 --- a/tests/cmds/test_trustedactivities.py +++ b/tests/cmds/test_trustedactivities.py @@ -42,7 +42,7 @@ MISSING_VALUE = MISSING_ARGUMENT_ERROR.format("VALUE") MISSING_RESOURCE_ID_ARG = MISSING_ARGUMENT_ERROR.format("RESOURCE_ID") RESOURCE_ID_NOT_FOUND_ERROR = "Resource ID '{}' not found." -INVALID_CHARACTER_ERROR = "Invalid character in domain or slack workspace name" +INVALID_CHARACTER_ERROR = "Invalid character in domain or Slack workspace name" CONFLICT_ERROR = ( "Duplicate URL or workspace name, '{}' already exists on your trusted list." ) diff --git a/tests/cmds/test_util.py b/tests/cmds/test_util.py new file mode 100644 index 000000000..fb3494197 --- /dev/null +++ b/tests/cmds/test_util.py @@ -0,0 +1,36 @@ +import pytest + +from code42cli import errors +from code42cli.cmds.util import try_get_default_header +from code42cli.output_formats import OutputFormat + +key = "events" + + +class TestQuery: + """""" + + pass + + +def search(*args, **kwargs): + pass + + +def test_try_get_default_header_raises_cli_error_when_using_include_all_with_none_table_format(): + with pytest.raises(errors.Code42CLIError) as err: + try_get_default_header(True, {}, OutputFormat.CSV) + + assert str(err.value) == "--include-all only allowed for Table output format." + + +def test_try_get_default_header_uses_default_header_when_not_include_all(): + default_header = {"default": "header"} + actual = try_get_default_header(False, default_header, OutputFormat.TABLE) + assert actual is default_header + + +def test_try_get_default_header_returns_none_when_is_table_and_told_to_include_all(): + default_header = {"default": "header"} + actual = try_get_default_header(True, default_header, OutputFormat.TABLE) + assert actual is None diff --git a/tests/integration/test_securitydata.py b/tests/integration/test_securitydata.py index ca5a6c858..88088b952 100644 --- a/tests/integration/test_securitydata.py +++ b/tests/integration/test_securitydata.py @@ -4,16 +4,20 @@ import pytest from tests.integration.conftest import append_profile +from tests.integration.util import assert_test_is_successful from code42cli.main import cli +begin_date = datetime.utcnow() - timedelta(days=20) +end_date = datetime.utcnow() - timedelta(days=10) +begin_date_str = begin_date.strftime("%Y-%m-%d") +end_date_str = end_date.strftime("%Y-%m-%d") + @pytest.mark.integration def test_security_data_send_to_tcp_return_success_return_code( runner, integration_test_profile, tcp_dataserver ): - begin_date = datetime.utcnow() - timedelta(days=20) - begin_date_str = begin_date.strftime("%Y-%m-%d") command = append_profile( f"security-data send-to localhost:5140 -p TCP -b '{begin_date_str}'" ) @@ -25,10 +29,29 @@ def test_security_data_send_to_tcp_return_success_return_code( def test_security_data_send_to_udp_return_success_return_code( runner, integration_test_profile, udp_dataserver ): - begin_date = datetime.utcnow() - timedelta(days=20) - begin_date_str = begin_date.strftime("%Y-%m-%d") command = append_profile( f"security-data send-to localhost:5141 -p UDP -b '{begin_date_str}'" ) result = runner.invoke(cli, split_command(command)) assert result.exit_code == 0 + + +@pytest.mark.integration +def test_security_data_advanced_query_returns_success_return_code( + runner, integration_test_profile +): + advanced_query = """{"groupClause":"AND", "groups":[{"filterClause":"AND", + "filters":[{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"2020-09-13T00:00:00.000Z"}, + {"operator":"ON_OR_BEFORE", "term":"eventTimestamp", "value":"2020-12-07T13:20:15.195Z"}]}], + "srtDir":"asc", "srtKey":"eventId", "pgNum":1, "pgSize":10000} + """ + command = f"security-data search --advanced-query '{advanced_query}'" + assert_test_is_successful(runner, append_profile(command)) + + +@pytest.mark.integration +def test_security_data_search_command_returns_success_return_code( + runner, integration_test_profile +): + command = f"security-data search -b {begin_date_str} -e {end_date_str}" + assert_test_is_successful(runner, append_profile(command)) diff --git a/tox.ini b/tox.ini index eb249c068..8140d4a1b 100644 --- a/tox.ini +++ b/tox.ini @@ -45,7 +45,6 @@ deps = pytest-mock == 2.0.0 pytest-cov == 2.10.0 git+https://github.com/code42/py42.git@master#egg=py42 - git+ssh://git@github.com/code42/c42eventextractor.git@master#egg=c42eventextractor [testenv:integration] commands = From 66c457bccd0e22d9cbce318705915b4faa73ec13 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 15 Oct 2021 14:35:58 -0500 Subject: [PATCH 281/349] Bugfix/trusted activities bulk create help text (#332) * fix bulk create help text * changelog --- CHANGELOG.md | 4 ++++ src/code42cli/cmds/trustedactivities.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f8007a77..00cc5a091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased +### Fixed + +- Incorrect column title on `code42 trusted-activities bulk create` command help text. + ### Added - New option `--include-roles` on `code42 users list` that includes the roles for all users. diff --git a/src/code42cli/cmds/trustedactivities.py b/src/code42cli/cmds/trustedactivities.py index 7c667da49..1397be5b2 100644 --- a/src/code42cli/cmds/trustedactivities.py +++ b/src/code42cli/cmds/trustedactivities.py @@ -132,7 +132,8 @@ def bulk(state): @bulk.command( name="create", help="Bulk create trusted activities using a CSV file with " - f"format: {','.join(TRUST_UPDATE_HEADERS)}.", + f"format: {','.join(TRUST_CREATE_HEADERS)}.\b\n\n" + f"Available `type` values are: {'|'.join(TrustedActivityType.choices())}", ) @read_csv_arg(headers=TRUST_CREATE_HEADERS) @sdk_options() From 6e49d472bb9f16c2d28614c7da90eb95f702c25d Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Mon, 18 Oct 2021 14:13:00 -0500 Subject: [PATCH 282/349] Feature/add bulk user role commands (#331) * bulk user role commands * bulk user roles * bulk cmds * fixing unit tests * renaming variable * add cache decorate to _get_role_id() * changed to lru_cache for <3.9 compatability * fix --- CHANGELOG.md | 4 + src/code42cli/cmds/users.py | 85 +++++++++++++++++++++ tests/cmds/test_users.py | 148 ++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00cc5a091..4ef1a5411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- New bulk commands to manage user roles + - `code42 users bulk add-roles` + - `code42 users bulk remove-roles` + - New option `--include-roles` on `code42 users list` that includes the roles for all users. - New command `code42 users show ` that prints all the details of that user. diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index db497c048..97290c3ac 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -1,3 +1,5 @@ +import functools + import click from pandas import DataFrame from pandas import json_normalize @@ -217,6 +219,8 @@ def reactivate(state, username): _bulk_user_move_headers = ["username", "org_id"] +_bulk_user_roles_headers = ["username", "role_name"] + @users.command(name="move") @username_option("The username of the user to move.", required=True) @@ -461,6 +465,86 @@ def handle_row(**row): formatter.echo_formatted_list(result_rows) +@bulk.command( + name="add-roles", + help=f"Add roles to a list of users from the provided CSV in format: {','.join(_bulk_user_roles_headers)}", +) +@read_csv_arg(headers=_bulk_user_roles_headers) +@format_option +@sdk_options() +def bulk_add_roles(state, csv_rows, format): + """Bulk add roles to a list of users.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + status_header = "role added" + + csv_rows[0][status_header] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) + + def handle_row(**row): + try: + _add_user_role( + sdk, **{key: row[key] for key in row.keys() if key != status_header} + ) + row[status_header] = "True" + except Exception as err: + row[status_header] = f"False: {err}" + stats.increment_total_errors() + return row + + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Adding roles to users:", + stats=stats, + raise_global_error=False, + ) + formatter.echo_formatted_list(result_rows) + + +@bulk.command( + name="remove-roles", + help=f"Remove roles from a list of users from the provided CSV in format: {','.join(_bulk_user_roles_headers)}", +) +@read_csv_arg(headers=_bulk_user_roles_headers) +@format_option +@sdk_options() +def bulk_remove_roles(state, csv_rows, format): + """Bulk remove roles from a list of users.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + success_header = "role removed" + + csv_rows[0][success_header] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) + + def handle_row(**row): + try: + _remove_user_role( + sdk, **{key: row[key] for key in row.keys() if key != success_header} + ) + row[success_header] = "True" + except Exception as err: + row[success_header] = f"False: {err}" + stats.increment_total_errors() + return row + + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Removing roles from users:", + stats=stats, + raise_global_error=False, + ) + formatter.echo_formatted_list(result_rows) + + def _add_user_role(sdk, username, role_name): user_id = _get_legacy_user_id(sdk, username) _get_role_id(sdk, role_name) # function provides role name validation @@ -484,6 +568,7 @@ def _get_legacy_user_id(sdk, username): return user_id +@functools.lru_cache() def _get_role_id(sdk, role_name): try: roles_dataframe = DataFrame.from_records( diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 79648c9b4..9e8d1d5a7 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -1108,6 +1108,154 @@ def _get(username, *args, **kwargs): assert worker_stats.increment_total_errors.call_count == 1 +def test_bulk_add_roles_uses_expected_arguments(runner, mocker, cli_state_with_user): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_add_roles.csv", "w") as csv: + csv.writelines( + ["username,role_name\n", f"{TEST_USERNAME},{TEST_ROLE_NAME}\n"] + ) + command = ["users", "bulk", "add-roles", "test_bulk_add_roles.csv"] + runner.invoke( + cli, command, obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "role_name": TEST_ROLE_NAME, "role added": "False"}, + ] + bulk_processor.assert_called_once() + + +def test_bulk_add_roles_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_add_roles.csv", "w") as csv: + csv.writelines( + ["username,role_name\n\n\n", f"{TEST_USERNAME},{TEST_ROLE_NAME}\n\n\n"] + ) + runner.invoke( + cli, + ["users", "bulk", "add-roles", "test_bulk_add_roles.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "role_name": TEST_ROLE_NAME, "role added": "False"}, + ] + bulk_processor.assert_called_once() + + +def test_bulk_add_roles_uses_handler_that_when_encounters_error_increments_total_errors( + runner, + mocker, + cli_state, + worker_stats, + get_users_response, + get_available_roles_success, +): + def _get(username, *args, **kwargs): + if username == "test@example.com": + raise Exception("TEST") + return get_users_response + + cli_state.sdk.users.get_by_username.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_add_roles.csv", "w") as csv: + csv.writelines( + ["username,role_name\n", f"{TEST_USERNAME},{TEST_ROLE_NAME}\n"] + ) + + runner.invoke( + cli, + ["users", "bulk", "add-roles", "test_bulk_add_roles.csv"], + obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + + handler( + username="test@example.com", role_name=TEST_ROLE_NAME, + ) + handler(username="not.test@example.com", role_name=TEST_ROLE_NAME) + assert worker_stats.increment_total_errors.call_count == 1 + + +def test_bulk_remove_roles_uses_expected_arguments(runner, mocker, cli_state_with_user): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_remove_roles.csv", "w") as csv: + csv.writelines( + ["username,role_name\n", f"{TEST_USERNAME},{TEST_ROLE_NAME}\n"] + ) + command = ["users", "bulk", "remove-roles", "test_bulk_remove_roles.csv"] + runner.invoke( + cli, command, obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + { + "username": TEST_USERNAME, + "role_name": TEST_ROLE_NAME, + "role removed": "False", + }, + ] + bulk_processor.assert_called_once() + + +def test_bulk_remove_roles_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_remove_roles.csv", "w") as csv: + csv.writelines( + ["username,role_name\n\n\n", f"{TEST_USERNAME},{TEST_ROLE_NAME}\n\n\n"] + ) + runner.invoke( + cli, + ["users", "bulk", "remove-roles", "test_bulk_remove_roles.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + { + "username": TEST_USERNAME, + "role_name": TEST_ROLE_NAME, + "role removed": "False", + }, + ] + bulk_processor.assert_called_once() + + +def test_bulk_remove_roles_uses_handler_that_when_encounters_error_increments_total_errors( + runner, + mocker, + cli_state, + worker_stats, + get_users_response, + get_available_roles_success, +): + def _get(username, *args, **kwargs): + if username == "test@example.com": + raise Exception("TEST") + + return get_users_response + + cli_state.sdk.users.get_by_username.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_remove_roles.csv", "w") as csv: + csv.writelines( + ["username,role_name\n", f"{TEST_USERNAME},{TEST_ROLE_NAME}\n"] + ) + + runner.invoke( + cli, + ["users", "bulk", "remove-roles", "test_bulk_remove_roles.csv"], + obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler( + username="test@example.com", role_name=TEST_ROLE_NAME, + ) + handler(username="not.test@example.com", role_name=TEST_ROLE_NAME) + assert worker_stats.increment_total_errors.call_count == 1 + + def test_orgs_list_calls_orgs_get_all_with_expected_params(runner, cli_state): runner.invoke(cli, ["users", "orgs", "list"], obj=cli_state) assert cli_state.sdk.orgs.get_all.call_count == 1 From bb7dc5b24683788e276d3be0bba17c21201fea35 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 20 Oct 2021 14:22:23 -0500 Subject: [PATCH 283/349] Exclude recently connected devices prior to filtering by date. (#333) * drop most-recently-connected devices prior to filtering by date-connected or date-created. * changelog --- CHANGELOG.md | 1 + src/code42cli/cmds/devices.py | 14 +++++++------- tests/cmds/test_devices.py | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef1a5411..96fb59e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Fixed - Incorrect column title on `code42 trusted-activities bulk create` command help text. +- `code42 devices list` will now process `--exclude-most-recently-connected` prior to `--last-connected-before` instead of after. ### Added diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index db766fa2b..c43ed80ba 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -334,6 +334,13 @@ def list_devices( org_uid, (include_backup_usage or include_total_storage), ) + if exclude_most_recently_connected: + most_recent = ( + df.sort_values(["userUid", "lastConnected"], ascending=False) + .groupby("userUid") + .head(exclude_most_recently_connected) + ) + df = df.drop(most_recent.index) if last_connected_after: df = df.loc[to_datetime(df.lastConnected) > last_connected_after] if last_connected_before: @@ -342,13 +349,6 @@ def list_devices( df = df.loc[to_datetime(df.creationDate) > created_after] if created_before: df = df.loc[to_datetime(df.creationDate) < created_before] - if exclude_most_recently_connected: - most_recent = ( - df.sort_values(["userUid", "lastConnected"], ascending=False) - .groupby("userUid") - .head(exclude_most_recently_connected) - ) - df = df.drop(most_recent.index) if include_total_storage: df = _add_storage_totals_to_dataframe(df, include_backup_usage) if include_settings: diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 4f803b798..630f9cd27 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -742,6 +742,24 @@ def test_list_invalid_org_uid_raises_error(runner, cli_state, custom_error): ) +def test_list_excludes_recently_connected_devices_before_filtering_by_date( + runner, cli_state, get_all_devices_success, +): + result = runner.invoke( + cli, + [ + "devices", + "list", + "--exclude-most-recently-connected", + "1", + "--last-connected-before", + TEST_DATE_NEWER, + ], + obj=cli_state, + ) + assert "839648314463407622" in result.output + + def test_list_backup_sets_invalid_org_uid_raises_error(runner, cli_state, custom_error): custom_error.response.text = "Unable to find org" invalid_org_uid = "invalid_org_uid" From 60faeeb1f5db09416a04610d375bd29fb219240b Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Thu, 21 Oct 2021 14:25:24 -0500 Subject: [PATCH 284/349] fix python version requirements (#334) --- CHANGELOG.md | 1 + README.md | 3 +-- setup.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96fb59e05..0c9d55aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta - Incorrect column title on `code42 trusted-activities bulk create` command help text. - `code42 devices list` will now process `--exclude-most-recently-connected` prior to `--last-connected-before` instead of after. +- The minimum required version of Python for code42cli is now correctly set as 3.6.2. ### Added diff --git a/README.md b/README.md index 74b627ac2..a3bb56d68 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,7 @@ Use the `code42` command to interact with your Code42 environment. ## Requirements -- Python 3.6.0+ -- Code42 Server 6.8.x+ +- Python 3.6.2+ ## Installation diff --git a/setup.py b/setup.py index 44c9bae4b..083a40d43 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ package_dir={"": "src"}, include_package_data=True, zip_safe=False, - python_requires=">3, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4", + python_requires=">=3.6.2, <4", install_requires=[ "chardet", "click>=7.1.1, <8", @@ -37,7 +37,7 @@ "colorama>=0.4.3", "keyring==18.0.1", "keyrings.alt==3.2.0", - "ipython>=7.16.1", + "ipython==7.16.1", "pandas>=1.1.3", "py42>=1.19.1", ], From c22ce9b20d488d041dd40f3ab67e85d931fc1bab Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Fri, 22 Oct 2021 10:35:45 -0500 Subject: [PATCH 285/349] v1.11.0 release prep (#335) * 1.11.0 release prep; require latest version of py42 * comment out mock-server build actions * Update CHANGELOG.md --- .github/workflows/build.yml | 52 ++++++++++++++++++------------------ CHANGELOG.md | 2 +- setup.py | 2 +- src/code42cli/__version__.py | 2 +- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d381040b..f0bb6f2ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,29 +32,29 @@ jobs: uses: codecov/codecov-action@v1.0.7 with: file: code42cli/coverage.xml - - name: Checkout mock servers - uses: actions/checkout@v2 - with: - repository: code42/code42-mock-servers - path: code42-mock-servers - - name: Add mock servers host addresses - run: | - sudo tee -a /etc/hosts <=1.1.3", - "py42>=1.19.1", + "py42>=1.19.2", ], extras_require={ "dev": [ diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index fcfdf3836..f84c53b0f 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.10.0" +__version__ = "1.11.0" From 665c5a8f5f0ceb74689fd6c69cfd1f1d6a422be8 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 1 Nov 2021 09:30:13 -0500 Subject: [PATCH 286/349] Bugfix/windows unicode with pipes (#338) * add handling of UnicodeEncodeError * use explicit newlines so on Windows it doesn't get doubled * output real unicode in json * clarify error message * update error msg again --- src/code42cli/click_ext/groups.py | 10 ++++++++++ src/code42cli/output_formats.py | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index 83ebcf01b..942de3e80 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -1,4 +1,5 @@ import difflib +import platform import re from collections import OrderedDict @@ -100,6 +101,15 @@ def invoke(self, ctx): self.logger.log_verbose_error(self._original_args, err.response.request) raise LoggedCLIError("Problem making request to server.") + except UnicodeEncodeError: + if platform.system() == "Windows": + cmd = '$ENV:PYTHONIOENCODING="utf-16"' + else: + cmd = 'export PYTHONIOENCODING="utf-8"' + raise Code42CLIError( + f"Failed to handle unicode character using environment's detected encoding, try running:\n\n {cmd}\n\nand then re-run your `code42` command." + ) + except OSError: raise diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index a9cc7f99f..6f2f48f4a 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -96,6 +96,7 @@ def get_formatted_output(self, df, **kwargs): "orient": "records", "lines": True, "index": True, + "force_ascii": False, "default_handler": str, } defaults.update(kwargs) @@ -106,13 +107,14 @@ def get_formatted_output(self, df, **kwargs): "orient": "records", "lines": False, "index": True, + "force_ascii": False, "default_handler": str, } defaults.update(kwargs) return df.to_json(**defaults) elif self.output_format == OutputFormat.CSV: - defaults = {"index": False} + defaults = {"index": False, "line_terminator": "\n"} defaults.update(kwargs) df = df.fillna("") return df.to_csv(**defaults) From e0b2ac3f178f2a5f7d88b0d1f27f459739ddb2c7 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 2 Nov 2021 12:06:54 -0500 Subject: [PATCH 287/349] add powershell/cmd.exe specific cmds (#339) --- src/code42cli/click_ext/groups.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index 942de3e80..5273f2f79 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -103,11 +103,11 @@ def invoke(self, ctx): except UnicodeEncodeError: if platform.system() == "Windows": - cmd = '$ENV:PYTHONIOENCODING="utf-16"' + cmd = 'if using powershell: $ENV:PYTHONIOENCODING="utf-16"\nif using cmd.exe: SET PYTHONIOENCODING="utf-16"' else: cmd = 'export PYTHONIOENCODING="utf-8"' raise Code42CLIError( - f"Failed to handle unicode character using environment's detected encoding, try running:\n\n {cmd}\n\nand then re-run your `code42` command." + f"Failed to handle unicode character using environment's detected encoding, try running the following:\n\n{cmd}\n\nand then re-run your `code42` command." ) except OSError: From e6c878ad78c1031bea8cdc8e166f63c5bc247c1b Mon Sep 17 00:00:00 2001 From: Ryan Haley <87095328+ryan-haley-code42@users.noreply.github.com> Date: Tue, 9 Nov 2021 14:24:00 -0600 Subject: [PATCH 288/349] Update py42 to v1.19.3 (#340) --- CHANGELOG.md | 5 +++++ setup.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb044e0ad..bc37aa63b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Changed +- Updated minimum version of py42 to `1.19.3` to provide access to updated URI paths for new standardized versioning scheme + ## 1.11.0 - 2021-10-22 ### Fixed diff --git a/setup.py b/setup.py index cde7e1185..6684d30e7 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython==7.16.1", "pandas>=1.1.3", - "py42>=1.19.2", + "py42>=1.19.3", ], extras_require={ "dev": [ From e08765cae022cdfce64687f5eb0c04905db0f3eb Mon Sep 17 00:00:00 2001 From: Ryan Haley <87095328+ryan-haley-code42@users.noreply.github.com> Date: Tue, 9 Nov 2021 15:33:02 -0600 Subject: [PATCH 289/349] Version 1.11.1 release (#342) * version bump * enable integration tests * Correcting date on changelog release --- .github/workflows/build.yml | 52 ++++++++++++++++++------------------ CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0bb6f2ab..0d381040b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,29 +32,29 @@ jobs: uses: codecov/codecov-action@v1.0.7 with: file: code42cli/coverage.xml -# - name: Checkout mock servers -# uses: actions/checkout@v2 -# with: -# repository: code42/code42-mock-servers -# path: code42-mock-servers -# - name: Add mock servers host addresses -# run: | -# sudo tee -a /etc/hosts < Date: Tue, 30 Nov 2021 08:58:47 -0600 Subject: [PATCH 290/349] Improved DataFrameOutputFormatter (#341) * refactor DataFrameOutputFormatter to enable checkpointing * organize and fix json extra newline at end * fix tests * whitespace * add tuple to ensure_iterable * rm test file * missing s * output real unicode in json * move kwargs default out of loop * refactor duplicated option definitions * define column option * DF formatter improvements * switch securitydata to use DFOutputFormatter everywhere * add/improve docstrings * fix output_format tests * test iterrows * default FileEventsOutputFormatter to RAW * handle when checkpoint key isn't in columns selected * add notes back to saved-search list * fix test_securitydata * add test for checkpointing when column missing * rm df.html * style * fix null handling in newer pandas versions * fix table format tests for differing pandas versions * max tox pandas requires match pkg * Auto print "No results found" from echo_formatted_dataframes * style and changelog * add test for echo-ing "No results found." on emtpy df * remove uneeded check for empty df * rm check for empty dfs in user cmds * elif --- CHANGELOG.md | 6 +- src/code42cli/cmds/alerts.py | 4 +- src/code42cli/cmds/devices.py | 14 +- src/code42cli/cmds/search/options.py | 18 +- src/code42cli/cmds/securitydata.py | 283 ++++++++++-------------- src/code42cli/cmds/users.py | 14 +- src/code42cli/enums.py | 31 +++ src/code42cli/logger/__init__.py | 2 +- src/code42cli/options.py | 12 +- src/code42cli/output_formats.py | 295 ++++++++++++++++++------- tests/cmds/search/test_init.py | 2 +- tests/cmds/test_securitydata.py | 10 + tests/integration/test_securitydata.py | 6 +- tests/logger/test_init.py | 4 +- tests/test_output_formats.py | 188 +++++++++------- tox.ini | 2 +- 16 files changed, 537 insertions(+), 354 deletions(-) create mode 100644 src/code42cli/enums.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 577424742..522d88c2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,12 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## 1.11.1 - 2021-11-09 +### Added +- `--columns` option to `security-data search` and `security-data send-to` commands which reduces output to only the specified colums/json keys. Accepts a comma-separated list of column names (case-insensitive). + ### Changed -- Updated minimum version of py42 to `1.19.3` to provide access to updated URI paths for new standardized versioning scheme +- Updated minimum version of py42 to `1.19.3` to provide access to updated URI paths for new standardized versioning scheme. +- Improved accuracy of checkpointing for `security-data search` (checkpoints every row as it is printed to stdout instead of just the last event of the search response). ## 1.11.0 - 2021-10-22 diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index b51457372..1a55bc26d 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -21,10 +21,10 @@ from code42cli.cmds.util import try_get_default_header from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import limit_date_range +from code42cli.enums import JsonOutputFormat +from code42cli.enums import OutputFormat from code42cli.file_readers import read_csv_arg from code42cli.options import format_option -from code42cli.output_formats import JsonOutputFormat -from code42cli.output_formats import OutputFormat from code42cli.output_formats import OutputFormatter from code42cli.util import hash_event from code42cli.util import parse_timestamp diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index c43ed80ba..18530c544 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -357,11 +357,8 @@ def list_devices( df = _add_usernames_to_device_dataframe(state.sdk, df) if include_legal_hold_membership: df = _add_legal_hold_membership_to_device_dataframe(state.sdk, df) - if df.empty: - click.echo("No results found.") - else: - formatter = DataFrameOutputFormatter(format) - formatter.echo_formatted_dataframe(df) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframes(df) def _add_legal_hold_membership_to_device_dataframe(sdk, df): @@ -493,11 +490,8 @@ def list_backup_sets( if include_usernames: df = _add_usernames_to_device_dataframe(state.sdk, df) df = _add_backup_set_settings_to_dataframe(state.sdk, df) - if df.empty: - click.echo("No results found.") - else: - formatter = DataFrameOutputFormatter(format) - formatter.echo_formatted_dataframe(df) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframes(df) def _add_backup_set_settings_to_dataframe(sdk, devices_dataframe): diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index 5055b593a..49bd78076 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -6,8 +6,8 @@ from code42cli.click_ext.options import incompatible_with from code42cli.click_ext.types import FileOrString +from code42cli.enums import SendToFileEventsOutputFormat from code42cli.logger.enums import ServerProtocol -from code42cli.output_formats import SendToFileEventsOutputFormat def is_in_filter(filter_cls): @@ -143,6 +143,22 @@ def advanced_query_option(term, **kwargs): return click.option("--advanced-query", **defaults) +or_query_option = click.option( + "--or-query", + is_flag=True, + cls=AdvancedQueryAndSavedSearchIncompatible, + help="Combine query filter options with 'OR' logic instead of the default 'AND'.", +) + +include_all_option = click.option( + "--include-all", + default=False, + is_flag=True, + help="Display simple properties of the primary level of the nested response.", + cls=incompatible_with("columns"), +) + + def server_options(f): hostname_arg = click.argument("hostname") protocol_option = click.option( diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 42eb70618..0fc27cb4d 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -3,6 +3,7 @@ import click import py42.sdk.queries.fileevents.filters as f from click import echo +from pandas import DataFrame from py42.exceptions import Py42InvalidPageTokenError from py42.sdk.queries.fileevents.file_event_query import FileEventQuery from py42.sdk.queries.fileevents.filters import InsertionTimestamp @@ -18,19 +19,18 @@ from code42cli.click_ext.types import MapChoice from code42cli.cmds.search import SendToCommand from code42cli.cmds.search.cursor_store import FileEventCursorStore -from code42cli.cmds.search.options import send_to_format_options -from code42cli.cmds.search.options import server_options from code42cli.cmds.util import convert_to_or_query from code42cli.cmds.util import create_time_range_filter -from code42cli.cmds.util import try_get_default_header from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import limit_date_range +from code42cli.enums import OutputFormat from code42cli.logger import get_main_cli_logger +from code42cli.options import column_option from code42cli.options import format_option from code42cli.options import sdk_options +from code42cli.output_formats import DataFrameOutputFormatter from code42cli.output_formats import FileEventsOutputFormat from code42cli.output_formats import FileEventsOutputFormatter -from code42cli.output_formats import OutputFormatter from code42cli.util import warn_interrupt logger = get_main_cli_logger() @@ -276,29 +276,11 @@ def _get_saved_search_query(ctx, param, arg): ) -def _create_header_keys_map(): - return {"name": "Name", "id": "Id"} - - -def _create_search_header_map(): - return { - "fileName": "FileName", - "filePath": "FilePath", - "eventType": "Type", - "eventTimestamp": "EventTimestamp", - "fileCategory": "FileCategory", - "fileSize": "FileSize", - "fileOwner": "FileOwner", - "md5Checksum": "MD5Checksum", - "sha256Checksum": "SHA256Checksum", - "riskIndicators": "RiskIndicator", - "riskSeverity": "RiskSeverity", - } - - def search_options(f): + f = column_option(f) f = checkpoint_option(f) f = advanced_query_option(f) + f = searchopt.or_query_option(f) f = end_option(f) f = begin_option(f) return f @@ -342,16 +324,9 @@ def clear_checkpoint(state, checkpoint_name): @security_data.command() @file_event_options @search_options -@click.option( - "--or-query", is_flag=True, cls=searchopt.AdvancedQueryAndSavedSearchIncompatible -) @sdk_options() -@click.option( - "--include-all", - default=False, - is_flag=True, - help="Display simple properties of the primary level of the nested response.", -) +@column_option +@searchopt.include_all_option @file_events_format_option def search( state, @@ -362,84 +337,94 @@ def search( use_checkpoint, saved_search, or_query, + columns, include_all, **kwargs, ): """Search for file events.""" - output_header = try_get_default_header( - include_all, _create_search_header_map(), format - ) - formatter = FileEventsOutputFormatter(format, output_header) - cursor = _get_cursor(state, use_checkpoint) + if format == FileEventsOutputFormat.CEF and columns: + raise click.BadOptionUsage( + "columns", "--columns option can't be used with CEF format." + ) + # set default table columns + if format == OutputFormat.TABLE: + if not columns and not include_all: + columns = [ + "fileName", + "filePath", + "eventType", + "eventTimestamp", + "fileCategory", + "fileSize", + "fileOwner", + "md5Checksum", + "sha256Checksum", + "riskIndicators", + "riskSeverity", + ] + if use_checkpoint: - checkpoint_name = use_checkpoint - # if checkpoint name exists, checkpoint should be that eventId, - # otherwise it should set the initial value to "" - checkpoint = cursor.get(checkpoint_name) or "" - - # older app versions stored checkpoint as float timestamp. - # we handle those here until the next run containing events will store checkpoint as the last eventId - try: - state.search_filters.append( - InsertionTimestamp.on_or_after(float(checkpoint)) - ) - checkpoint = "" - except ValueError: - pass + cursor = _get_file_event_cursor_store(state.profile.name) + checkpoint = _handle_timestamp_checkpoint(cursor.get(use_checkpoint), state) + + def checkpoint_func(event): + cursor.replace(use_checkpoint, event["eventId"]) + else: - checkpoint = "" + checkpoint = checkpoint_func = None query = _construct_query(state, begin, end, saved_search, advanced_query, or_query) - events = _get_all_file_events(state, query, checkpoint) + dfs = _get_all_file_events(state, query, checkpoint) + formatter = FileEventsOutputFormatter(format, checkpoint_func=checkpoint_func) + # sending to pager when checkpointing can be inaccurate due to pager buffering, so disallow pager + force_no_pager = use_checkpoint + formatter.echo_formatted_dataframes( + dfs, columns=columns, force_no_pager=force_no_pager + ) - if use_checkpoint: - checkpoint_name = use_checkpoint - events = _store_updated_checkpoint(cursor, checkpoint_name, events) - events_list = [] - for event in events: - events_list.append(event) - if not events_list: - click.echo("No results found.") - return - formatter.echo_formatted_list(events_list) +@security_data.command(cls=SendToCommand) +@file_event_options +@search_options +@sdk_options() +@searchopt.server_options +@searchopt.send_to_format_options +def send_to( + state, + begin, + end, + advanced_query, + use_checkpoint, + saved_search, + or_query, + columns, + **kwargs, +): + """Send events to the given server address. + HOSTNAME format: address:port where port is optional and defaults to 514. + """ + if use_checkpoint: + cursor = _get_file_event_cursor_store(state.profile.name) + checkpoint = _handle_timestamp_checkpoint(cursor.get(use_checkpoint), state) -def _construct_query(state, begin, end, saved_search, advanced_query, or_query): + def checkpoint_func(event): + cursor.replace(use_checkpoint, event["eventId"]) - if advanced_query: - state.search_filters = advanced_query - if saved_search: - state.search_filters = saved_search._filter_group_list else: - if begin or end: - state.search_filters.append( - create_time_range_filter(f.EventTimestamp, begin, end) - ) - if or_query: - state.search_filters = convert_to_or_query(state.search_filters) - query = FileEventQuery(*state.search_filters) - query.page_size = MAX_EVENT_PAGE_SIZE - query.sort_direction = "asc" - query.sort_key = "insertionTimestamp" - return query - + checkpoint = checkpoint_func = None -def _get_all_file_events(state, query, checkpoint=""): + query = _construct_query(state, begin, end, saved_search, advanced_query, or_query) + dfs = _get_all_file_events(state, query, checkpoint) + formatter = FileEventsOutputFormatter(None, checkpoint_func=checkpoint_func) - try: - response = state.sdk.securitydata.search_all_file_events( - query, page_token=checkpoint - ) - except Py42InvalidPageTokenError: - response = state.sdk.securitydata.search_all_file_events(query) - yield from response["fileEvents"] - while response["nextPgToken"]: - response = state.sdk.securitydata.search_all_file_events( - query, page_token=response["nextPgToken"] - ) - yield from response["fileEvents"] + with warn_interrupt(): + event = None + for event in formatter.iter_rows(dfs, columns=columns): + state.logger.info(event) + if event is None: # generator was empty + click.echo("No results found.") @security_data.group(cls=OrderedGroup) @@ -454,11 +439,12 @@ def saved_search(state): @sdk_options() def _list(state, format=None): """List available saved searches.""" - formatter = OutputFormatter(format, _create_header_keys_map()) + formatter = DataFrameOutputFormatter(format) response = state.sdk.securitydata.savedsearches.get() - saved_searches = response["searches"] - if saved_searches: - formatter.echo_formatted_list(saved_searches) + saved_searches_df = DataFrame(response["searches"]) + formatter.echo_formatted_dataframes( + saved_searches_df, columns=["name", "id", "notes"] + ) @saved_search.command() @@ -470,74 +456,49 @@ def show(state, search_id): echo(pformat(response["searches"])) -@security_data.command(cls=SendToCommand) -@file_event_options -@search_options -@click.option( - "--or-query", - is_flag=True, - cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, - help="Combine query filter options with 'OR' logic instead of the default 'AND'.", -) -@sdk_options() -@server_options -@click.option( - "--include-all", - default=False, - is_flag=True, - help="Display simple properties of the primary level of the nested response.", -) -@send_to_format_options -def send_to( - state, begin, end, advanced_query, use_checkpoint, saved_search, or_query, **kwargs, -): - """Send events to the given server address. +def _get_file_event_cursor_store(profile_name): + return FileEventCursorStore(profile_name) - HOSTNAME format: address:port where port is optional and defaults to 514. - """ - cursor = _get_cursor(state, use_checkpoint) - if use_checkpoint: - checkpoint_name = use_checkpoint - # if checkpoint name exists, checkpoint should be that eventId, - # otherwise it should set the initial value to "" - checkpoint = cursor.get(checkpoint_name) or "" - - # older app versions stored checkpoint as float timestamp. - # we handle those here until the next run containing events will store checkpoint as the last eventId - try: +def _construct_query(state, begin, end, saved_search, advanced_query, or_query): + + if advanced_query: + state.search_filters = advanced_query + elif saved_search: + state.search_filters = saved_search._filter_group_list + else: + if begin or end: state.search_filters.append( - InsertionTimestamp.on_or_after(float(checkpoint)) + create_time_range_filter(f.EventTimestamp, begin, end) ) - checkpoint = "" - except ValueError: - pass - else: - checkpoint = "" - - query = _construct_query(state, begin, end, saved_search, advanced_query, or_query) - events = _get_all_file_events(state, query, checkpoint) - - with warn_interrupt(): - event = None - for event in events: - if use_checkpoint: - checkpoint_name = use_checkpoint - cursor.replace(checkpoint_name, event["eventId"]) - state.logger.info(event) - if event is None: # generator was empty - click.echo("No results found.") - - -def _get_cursor(state, use_checkpoint): - return _get_file_event_cursor_store(state.profile.name) if use_checkpoint else None + if or_query: + state.search_filters = convert_to_or_query(state.search_filters) + query = FileEventQuery(*state.search_filters) + query.page_size = MAX_EVENT_PAGE_SIZE + query.sort_direction = "asc" + query.sort_key = "insertionTimestamp" + return query -def _get_file_event_cursor_store(profile_name): - return FileEventCursorStore(profile_name) +def _get_all_file_events(state, query, checkpoint=""): + try: + response = state.sdk.securitydata.search_all_file_events( + query, page_token=checkpoint + ) + except Py42InvalidPageTokenError: + response = state.sdk.securitydata.search_all_file_events(query) + yield DataFrame(response["fileEvents"]) + while response["nextPgToken"]: + response = state.sdk.securitydata.search_all_file_events( + query, page_token=response["nextPgToken"] + ) + yield DataFrame(response["fileEvents"]) -def _store_updated_checkpoint(cursor, checkpoint_name, events): - for event in events: - yield event - cursor.replace(checkpoint_name, event["eventId"]) +def _handle_timestamp_checkpoint(checkpoint, state): + try: + checkpoint = float(checkpoint) + state.search_filters.append(InsertionTimestamp.on_or_after(checkpoint)) + return None + except (ValueError, TypeError): + return checkpoint diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 97290c3ac..39a62333f 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -103,11 +103,8 @@ def list_users( ) if include_legal_hold_membership: df = _add_legal_hold_membership_to_user_dataframe(state.sdk, df) - if df.empty: - click.echo("No results found.") - else: - formatter = DataFrameOutputFormatter(format) - formatter.echo_formatted_dataframe(df) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframes(df) @users.command("show") @@ -126,11 +123,8 @@ def show_user(state, username, include_legal_hold_membership, format): df = DataFrame.from_records(response["users"], columns=columns) if include_legal_hold_membership and not df.empty: df = _add_legal_hold_membership_to_user_dataframe(state.sdk, df) - if df.empty: - click.echo("No results found.") - else: - formatter = DataFrameOutputFormatter(format) - formatter.echo_formatted_dataframe(df) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframes(df) @users.command() diff --git a/src/code42cli/enums.py b/src/code42cli/enums.py new file mode 100644 index 000000000..a1c44a861 --- /dev/null +++ b/src/code42cli/enums.py @@ -0,0 +1,31 @@ +from py42.choices import Choices + + +class JsonOutputFormat(Choices): + JSON = "JSON" + RAW = "RAW-JSON" + + def __iter__(self): + return iter([self.JSON, self.RAW]) + + +class OutputFormat(JsonOutputFormat): + TABLE = "TABLE" + CSV = "CSV" + + def __iter__(self): + return iter([self.TABLE, self.CSV, self.JSON, self.RAW]) + + +class SendToFileEventsOutputFormat(JsonOutputFormat): + CEF = "CEF" + + def __iter__(self): + return iter([self.CEF, self.JSON, self.RAW]) + + +class FileEventsOutputFormat(OutputFormat): + CEF = "CEF" + + def __iter__(self): + return iter([self.TABLE, self.CSV, self.JSON, self.RAW, self.CEF]) diff --git a/src/code42cli/logger/__init__.py b/src/code42cli/logger/__init__.py index 5e8350ae0..b501e6993 100644 --- a/src/code42cli/logger/__init__.py +++ b/src/code42cli/logger/__init__.py @@ -4,11 +4,11 @@ from logging.handlers import RotatingFileHandler from threading import Lock +from code42cli.enums import FileEventsOutputFormat from code42cli.logger.formatters import FileEventDictToCEFFormatter from code42cli.logger.formatters import FileEventDictToJSONFormatter from code42cli.logger.formatters import FileEventDictToRawJSONFormatter from code42cli.logger.handlers import NoPrioritySysLogHandler -from code42cli.output_formats import FileEventsOutputFormat from code42cli.util import get_url_parts from code42cli.util import get_user_project_path diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 29691d94e..0724b075f 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -7,10 +7,10 @@ from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import round_datetime_to_day_end from code42cli.date_helper import round_datetime_to_day_start +from code42cli.enums import OutputFormat +from code42cli.enums import SendToFileEventsOutputFormat from code42cli.errors import Code42CLIError from code42cli.logger.enums import ServerProtocol -from code42cli.output_formats import OutputFormat -from code42cli.output_formats import SendToFileEventsOutputFormat from code42cli.profile import get_profile from code42cli.sdk_client import create_sdk @@ -210,3 +210,11 @@ def set_end_default_dict(term): "the same as `--begin`.", callback=lambda ctx, param, arg: convert_datetime_to_timestamp(arg), ) + + +column_option = click.option( + "--columns", + default=None, + callback=lambda ctx, param, value: value.split(",") if value is not None else None, + help="Filter output to include only specified columns. Accepts comma-separated list of column names (case-insensitive).", +) diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 6f2f48f4a..be337235e 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -1,9 +1,16 @@ import csv import io import json +from itertools import chain +from typing import Generator import click +from pandas import concat +from pandas import notnull +from code42cli.enums import FileEventsOutputFormat +from code42cli.enums import OutputFormat +from code42cli.errors import Code42CLIError from code42cli.logger.formatters import CEF_TEMPLATE from code42cli.logger.formatters import map_event_to_cef from code42cli.util import find_format_width @@ -13,33 +20,10 @@ CEF_DEFAULT_PRODUCT_NAME = "Advanced Exfiltration Detection" CEF_DEFAULT_SEVERITY_LEVEL = "5" -# Uses method `output_via_pager()` when 10 or more records. +# Uses method `echo_via_pager()` when 10 or more records. OUTPUT_VIA_PAGER_THRESHOLD = 10 -class JsonOutputFormat: - JSON = "JSON" - RAW = "RAW-JSON" - - def __iter__(self): - return iter([self.JSON, self.RAW]) - - -class OutputFormat(JsonOutputFormat): - TABLE = "TABLE" - CSV = "CSV" - - def __iter__(self): - return iter([self.TABLE, self.CSV, self.JSON, self.RAW]) - - -class SendToFileEventsOutputFormat(JsonOutputFormat): - CEF = "CEF" - - def __iter__(self): - return iter([self.CEF, self.JSON, self.RAW]) - - class OutputFormatter: def __init__(self, output_format, header=None): output_format = output_format.upper() if output_format else OutputFormat.TABLE @@ -85,57 +69,220 @@ def _requires_list_output(self): class DataFrameOutputFormatter: - def __init__(self, output_format): + def __init__(self, output_format, checkpoint_func=None): self.output_format = ( output_format.upper() if output_format else OutputFormat.TABLE ) + if self.output_format not in OutputFormat.choices(): + raise Code42CLIError( + f"DataFrameOutputFormatter received an invalid format: {self.output_format}" + ) + self.checkpoint_func = checkpoint_func or (lambda x: None) + + def _ensure_iterable(self, dfs): + if not isinstance(dfs, (Generator, list, tuple)): + return [dfs] + return dfs + + def _iter_table(self, dfs, columns=None, **kwargs): + dfs = self._ensure_iterable(dfs) + df = concat(dfs) + if df.empty: + return + # convert everything to strings so we can left-justify format + df = df.fillna("").applymap(str) + # set overrideable default kwargs + kwargs = { + "index": False, + "justify": "left", + "formatters": make_left_aligned_formatter(df), + **kwargs, + } + if columns: + filtered = self._select_columns(df, columns) + formatted_rows = filtered.to_string(**kwargs).splitlines(keepends=True) + else: + formatted_rows = df.to_string(**kwargs).splitlines(keepends=True) + # don't checkpoint the header row + if kwargs.get("header") is not False: + yield formatted_rows.pop(0) + + yield from self._checkpoint_and_iter_formatted_events(df, formatted_rows) + + def _iter_csv(self, dfs, columns=None, **kwargs): + dfs = self._ensure_iterable(dfs) + no_header = kwargs.get("header") is False + + for i, df in enumerate(dfs): + if df.empty: + continue + # convert null values to empty string + df.fillna("", inplace=True) + # only add header on first df and if header=False was not passed in kwargs + header = False if no_header else (i == 0) + kwargs = {"index": False, "header": header, **kwargs} + if columns: + filtered = self._select_columns(df, columns) + formatted_rows = filtered.to_csv(**kwargs).splitlines(keepends=True) + else: + formatted_rows = df.to_csv(**kwargs).splitlines(keepends=True) + if header: + yield formatted_rows.pop(0) + + yield from self._checkpoint_and_iter_formatted_events(df, formatted_rows) + + def _iter_json(self, dfs, columns=None, **kwargs): + kwargs = {"ensure_ascii": False, **kwargs} + for event in self.iter_rows(dfs, columns=columns): + json_string = json.dumps(event, **kwargs) + yield f"{json_string}\n" + + def _checkpoint_and_iter_formatted_events(self, df, formatted_rows): + for event, row in zip(df.to_dict("records"), formatted_rows): + yield row + self.checkpoint_func(event) + + def _echo_via_pager_if_over_threshold(self, gen): + first_rows = [] + try: + for _ in range(OUTPUT_VIA_PAGER_THRESHOLD): + first_rows.append(next(gen)) + except StopIteration: + click.echo("".join(first_rows)) + return + + click.echo_via_pager(chain(first_rows, gen)) + + def _select_columns(self, df, columns): + if df.empty: + return df + if not isinstance(columns, (list, tuple)): + raise Code42CLIError( + "'columns' parameter must be a list or tuple of column names." + ) + # enable case-insensitive column selection + normalized_map = {c.lower(): c for c in df.columns} + try: + columns = [normalized_map[c.lower()] for c in columns] + return df[columns] + except KeyError as e: + key = e.args[0] + raise click.BadArgumentUsage( + f"'{key}' is not a valid column. Valid columns are: {list(df.columns)}" + ) - def get_formatted_output(self, df, **kwargs): - if self.output_format == OutputFormat.JSON: - defaults = { - "orient": "records", - "lines": True, - "index": True, - "force_ascii": False, - "default_handler": str, - } - defaults.update(kwargs) - return df.to_json(**defaults) - - elif self.output_format == OutputFormat.RAW: - defaults = { - "orient": "records", - "lines": False, - "index": True, - "force_ascii": False, - "default_handler": str, - } - defaults.update(kwargs) - return df.to_json(**defaults) + def iter_rows(self, dfs, columns=None): + """ + Accepts a pandas DataFrame or list/generator of DataFrames and yields each + 'row' of the DataFrame as a dict, calling the `checkpoint_func` on each row + after it has been yielded. + + Accepts an optional list of column names that filter + columns in the yielded results. + """ + dfs = self._ensure_iterable(dfs) + for df in dfs: + # convert pandas' default null (numpy.NaN) to None + df = df.astype(object).where(notnull, None) + if columns: + filtered = self._select_columns(df, columns) + else: + filtered = df + for full_event, filtered_event in zip( + df.to_dict("records"), filtered.to_dict("records") + ): + yield filtered_event + self.checkpoint_func(full_event) + + def get_formatted_output(self, dfs, columns=None, **kwargs): + """ + Accepts a pandas DataFrame or list/generator of DataFrames and formats and yields + the results line by line to the caller as a generator. + + Accepts an optional list of column names that filter columns in the yielded + results. + + Any additional kwargs provided will be passed to the underlying format method + if customizations are required. + """ + if self.output_format == OutputFormat.TABLE: + yield from self._iter_table(dfs, columns=columns, **kwargs) elif self.output_format == OutputFormat.CSV: - defaults = {"index": False, "line_terminator": "\n"} - defaults.update(kwargs) - df = df.fillna("") - return df.to_csv(**defaults) + yield from self._iter_csv(dfs, columns=columns, **kwargs) - elif self.output_format == OutputFormat.TABLE: - defaults = {"index": False} - defaults.update(kwargs) - df = df.fillna("") - return df.to_string(**defaults) + elif self.output_format == OutputFormat.JSON: + kwargs = {"indent": 4, **kwargs} + yield from self._iter_json(dfs, columns=columns, **kwargs) + + elif self.output_format == OutputFormat.RAW: + yield from self._iter_json(dfs, columns=columns, **kwargs) else: - raise ValueError( + raise Code42CLIError( f"DataFrameOutputFormatter received an invalid format: {self.output_format}" ) - def echo_formatted_dataframe(self, df, **kwargs): - str_output = self.get_formatted_output(df, **kwargs) - if len(df) <= OUTPUT_VIA_PAGER_THRESHOLD: - click.echo(str_output) + def echo_formatted_dataframes( + self, dfs, columns=None, force_pager=False, force_no_pager=False, **kwargs + ): + """ + Accepts a pandas DataFrame or list/generator of DataFrames and formats and echos the + result to stdout. If total lines > 10, results will be sent to pager. `force_pager` + and `force_no_pager` can be set to override the pager logic based on line count. + + Accepts an optional list of column names that filter + columns in the echoed results. + + Any additional kwargs provided will be passed to the underlying format method + if customizations are required. + """ + lines = self.get_formatted_output(dfs, columns=columns, **kwargs) + try: + # check for empty generator + first = next(lines) + lines = chain([first], lines) + except StopIteration: + click.echo("No results found.") + return + if force_pager and force_no_pager: + raise Code42CLIError("force_pager cannot be used with force_no_pager.") + if force_pager: + click.echo_via_pager(lines) + elif force_no_pager: + for line in lines: + click.echo(line) else: - click.echo_via_pager(str_output) + self._echo_via_pager_if_over_threshold(lines) + + +class FileEventsOutputFormatter(DataFrameOutputFormatter): + """Class that adds CEF format output option to base DataFrameOutputFormatter.""" + + def __init__(self, output_format, checkpoint_func=None): + self.output_format = ( + output_format.upper() if output_format else OutputFormat.RAW + ) + if self.output_format not in FileEventsOutputFormat.choices(): + raise Code42CLIError( + f"FileEventsOutputFormatter received an invalid format: {self.output_format}" + ) + self.checkpoint_func = checkpoint_func or (lambda x: None) + + def _iter_cef(self, dfs, **kwargs): + dfs = self._ensure_iterable(dfs) + for df in dfs: + df = df.mask(df.isna(), other=None) + for _i, row in df.iterrows(): + event = dict(row) + yield f"{_convert_event_to_cef(event)}\n" + self.checkpoint_func(event) + + def get_formatted_output(self, dfs, columns=None, **kwargs): + if self.output_format == FileEventsOutputFormat.CEF: + yield from self._iter_cef(dfs, **kwargs) + else: + yield from super().get_formatted_output(dfs, columns=columns, **kwargs) def to_csv(output): @@ -170,26 +317,6 @@ def to_formatted_json(output): return f"{json.dumps(output, indent=4)}\n" -class FileEventsOutputFormat(OutputFormat): - CEF = "CEF" - - def __iter__(self): - return iter([self.TABLE, self.CSV, self.JSON, self.RAW, self.CEF]) - - -class FileEventsOutputFormatter(OutputFormatter): - def __init__(self, output_format, header=None): - output_format = ( - output_format.upper() if output_format else FileEventsOutputFormat.TABLE - ) - super().__init__(output_format, header) - if output_format == FileEventsOutputFormat.CEF: - self._format_func = to_cef - - def echo_formatted_generated_output(self, event): - pass - - def to_cef(output): """Output is a single record""" return f"{_convert_event_to_cef(output)}\n" @@ -205,3 +332,7 @@ def _convert_event_to_cef(event): extension=ext, ) return cef_log + + +def make_left_aligned_formatter(df): + return {c: f"{{:<{df[c].str.len().max()}s}}".format for c in df.columns} diff --git a/tests/cmds/search/test_init.py b/tests/cmds/search/test_init.py index 01b689984..1d7327e28 100644 --- a/tests/cmds/search/test_init.py +++ b/tests/cmds/search/test_init.py @@ -1,9 +1,9 @@ import pytest from code42cli.cmds.search import _try_get_logger_for_server +from code42cli.enums import SendToFileEventsOutputFormat from code42cli.errors import Code42CLIError from code42cli.logger.enums import ServerProtocol -from code42cli.output_formats import SendToFileEventsOutputFormat _TEST_ERROR_MESSAGE = "TEST ERROR MESSAGE" diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 278885936..fdc887956 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -145,8 +145,13 @@ "eventTimestamp": TEST_FILE_EVENT_TIMESTAMP_1, "insertionTimestamp": TEST_FILE_EVENT_TIMESTAMP_1, "fileName": "test.txt", + "filePath": "/my/path", + "fileSize": 4242, + "fileOwner": "john.doe", "fileType": "FILE", "fileCategory": "Document", + "md5Checksum": "abcdef12345", + "sha256Checksum": "12345abcdef", "destinationCategory": "Cloud Storage", "destinationName": "Google Drive", "riskScore": 5, @@ -162,8 +167,13 @@ "eventTimestamp": TEST_FILE_EVENT_TIMESTAMP_2, "insertionTimestamp": TEST_FILE_EVENT_TIMESTAMP_2, "fileName": "test2.txt", + "filePath": "/my/path/2", + "fileSize": 4242, + "fileOwner": "john.doe", "fileType": "FILE", "fileCategory": "Document", + "md5Checksum": "abcdef1234567", + "sha256Checksum": "1234567abcdef", "destinationCategory": "Cloud Storage", "destinationName": "Google Drive", "riskScore": 5, diff --git a/tests/integration/test_securitydata.py b/tests/integration/test_securitydata.py index 88088b952..d2e457908 100644 --- a/tests/integration/test_securitydata.py +++ b/tests/integration/test_securitydata.py @@ -40,11 +40,7 @@ def test_security_data_send_to_udp_return_success_return_code( def test_security_data_advanced_query_returns_success_return_code( runner, integration_test_profile ): - advanced_query = """{"groupClause":"AND", "groups":[{"filterClause":"AND", - "filters":[{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"2020-09-13T00:00:00.000Z"}, - {"operator":"ON_OR_BEFORE", "term":"eventTimestamp", "value":"2020-12-07T13:20:15.195Z"}]}], - "srtDir":"asc", "srtKey":"eventId", "pgNum":1, "pgSize":10000} - """ + advanced_query = """{"groupClause":"AND", "groups":[{"filterClause":"AND","filters":[{"operator":"ON_OR_AFTER", "term":"eventTimestamp", "value":"2020-09-13T00:00:00.000Z"},{"operator":"ON_OR_BEFORE", "term":"eventTimestamp", "value":"2020-12-07T13:20:15.195Z"}]}],"srtDir":"asc", "srtKey":"eventId", "pgNum":1, "pgSize":10000}""" command = f"security-data search --advanced-query '{advanced_query}'" assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/logger/test_init.py b/tests/logger/test_init.py index ca424d181..109a181c6 100644 --- a/tests/logger/test_init.py +++ b/tests/logger/test_init.py @@ -5,6 +5,8 @@ import pytest from requests import Request +from code42cli.enums import OutputFormat +from code42cli.enums import SendToFileEventsOutputFormat from code42cli.logger import add_handler_to_logger from code42cli.logger import CliLogger from code42cli.logger import get_logger_for_server @@ -15,8 +17,6 @@ from code42cli.logger.formatters import FileEventDictToJSONFormatter from code42cli.logger.formatters import FileEventDictToRawJSONFormatter from code42cli.logger.handlers import NoPrioritySysLogHandler -from code42cli.output_formats import OutputFormat -from code42cli.output_formats import SendToFileEventsOutputFormat from code42cli.util import get_user_project_path diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index bbd2ed124..4c98f95c6 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -6,6 +6,7 @@ from pandas import DataFrame import code42cli.output_formats as output_formats_module +from code42cli.errors import Code42CLIError from code42cli.maps import FILE_EVENT_TO_SIGNATURE_ID_MAP from code42cli.output_formats import DataFrameOutputFormatter from code42cli.output_formats import FileEventsOutputFormat @@ -317,56 +318,6 @@ def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed mock_to_table.assert_called_once_with("TEST", None, include_header=True) -class TestFileEventsOutputFormatter: - def test_init_sets_format_func_to_dynamic_csv_function_when_csv_option_is_passed( - self, mock_to_csv - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CSV) - for _ in formatter.get_formatted_output("TEST"): - pass - mock_to_csv.assert_called_once_with("TEST") - - def test_init_sets_format_func_to_formatted_json_function_when_json__option_is_passed( - self, mock_to_formatted_json - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.JSON) - for _ in formatter.get_formatted_output(["TEST"]): - pass - mock_to_formatted_json.assert_called_once_with("TEST") - - def test_init_sets_format_func_to_json_function_when_raw_json_format_option_is_passed( - self, mock_to_json - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.RAW) - for _ in formatter.get_formatted_output(["TEST"]): - pass - mock_to_json.assert_called_once_with("TEST") - - def test_init_sets_format_func_to_cef_function_when_cef_format_option_is_passed( - self, mock_to_cef - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CEF) - for _ in formatter.get_formatted_output(["TEST"]): - pass - mock_to_cef.assert_called_once_with("TEST") - - def test_init_sets_format_func_to_table_function_when_table_format_option_is_passed( - self, mock_to_table - ): - formatter = FileEventsOutputFormatter(FileEventsOutputFormat.TABLE) - for _ in formatter.get_formatted_output("TEST"): - pass - mock_to_table.assert_called_once_with("TEST", None, include_header=True) - - def test_init_sets_format_func_to_table_function_when_no_format_option_is_passed( - self, mock_to_table - ): - formatter = FileEventsOutputFormatter(None) - for _ in formatter.get_formatted_output("TEST"): - pass - mock_to_table.assert_called_once_with("TEST", None, include_header=True) - - def test_to_cef_returns_cef_tagged_string(mock_file_event): cef_out = to_cef(mock_file_event) cef_parts = get_cef_parts(cef_out) @@ -530,7 +481,7 @@ def test_to_cef_includes_removable_media_serial_number_label_if_present( assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) -def test_to_cef_includes_actor_if_present(mock_file_event_cloud_activity_event,): +def test_to_cef_includes_actor_if_present(mock_file_event_cloud_activity_event): expected_field_name = "suser" expected_value = "actor@example.com" cef_out = to_cef(mock_file_event_cloud_activity_event) @@ -609,7 +560,7 @@ def test_to_cef_includes_exposure_if_present(mock_file_event): assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) -def test_to_cef_includes_url_if_present(mock_file_event_cloud_activity_event,): +def test_to_cef_includes_url_if_present(mock_file_event_cloud_activity_event): expected_field_name = "filePath" expected_value = "https://www.example.com" cef_out = to_cef(mock_file_event_cloud_activity_event) @@ -667,35 +618,35 @@ def test_to_cef_includes_cloud_drive_id_if_present( assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) -def test_to_cef_includes_shared_with_if_present(mock_file_event_cloud_activity_event,): +def test_to_cef_includes_shared_with_if_present(mock_file_event_cloud_activity_event): expected_field_name = "duser" expected_value = "example1@example.com,example2@example.com" cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) -def test_to_cef_includes_tab_url_if_present(mock_file_event_cloud_activity_event,): +def test_to_cef_includes_tab_url_if_present(mock_file_event_cloud_activity_event): expected_field_name = "request" expected_value = "TEST_TAB_URL" cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) -def test_to_cef_includes_window_title_if_present(mock_file_event_cloud_activity_event,): +def test_to_cef_includes_window_title_if_present(mock_file_event_cloud_activity_event): expected_field_name = "requestClientApplication" expected_value = "TEST_WINDOW_TITLE" cef_out = to_cef(mock_file_event_cloud_activity_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) -def test_to_cef_includes_email_recipients_if_present(mock_file_event_email_event,): +def test_to_cef_includes_email_recipients_if_present(mock_file_event_email_event): expected_field_name = "duser" expected_value = "test.recipient1@example.com,test.recipient2@example.com" cef_out = to_cef(mock_file_event_email_event) assert key_value_pair_in_cef_extension(expected_field_name, expected_value, cef_out) -def test_to_cef_includes_email_sender_if_present(mock_file_event_email_event,): +def test_to_cef_includes_email_sender_if_present(mock_file_event_email_event): expected_field_name = "suser" expected_value = "TEST_EMAIL_SENDER" cef_out = to_cef(mock_file_event_email_event) @@ -785,43 +736,54 @@ def test_format_when_none_passed_defaults_to_table(self): assert formatter.output_format == OutputFormat.TABLE def test_format_when_unknown_format_raises_value_error(self): - with pytest.raises(ValueError): - formatter = DataFrameOutputFormatter("NOT_A_FORMAT") - formatter.get_formatted_output(self.test_df) + with pytest.raises(Code42CLIError): + DataFrameOutputFormatter("NOT_A_FORMAT") + + with pytest.raises(Code42CLIError): + formatter = DataFrameOutputFormatter("JSON") + formatter.output_format = "NOT_A_FORMAT" + list(formatter.get_formatted_output(self.test_df)) def test_json_formatter_converts_to_expected_string(self): formatter = DataFrameOutputFormatter(OutputFormat.JSON) output = formatter.get_formatted_output(self.test_df) assert ( - output.strip() - == '{"string_column":"string1","int_column":42,"null_column":null}\n{"string_column":"string2","int_column":43,"null_column":null}' + "".join(output) + == '{\n "string_column": "string1",\n "int_column": 42,\n "null_column": null\n}\n{\n "string_column": "string2",\n "int_column": 43,\n "null_column": null\n}\n' ) def test_raw_formatter_converts_to_expected_string(self): formatter = DataFrameOutputFormatter(OutputFormat.RAW) output = formatter.get_formatted_output(self.test_df) assert ( - output - == '[{"string_column":"string1","int_column":42,"null_column":null},{"string_column":"string2","int_column":43,"null_column":null}]' + "".join(output) + == '{"string_column": "string1", "int_column": 42, "null_column": null}\n{"string_column": "string2", "int_column": 43, "null_column": null}\n' ) def test_csv_formatter_converts_to_expected_string(self): formatter = DataFrameOutputFormatter(OutputFormat.CSV) output = formatter.get_formatted_output(self.test_df) assert ( - output == "string_column,int_column,null_column\nstring1,42,\nstring2,43,\n" + "".join(output) + == "string_column,int_column,null_column\nstring1,42,\nstring2,43,\n" ) def test_table_formatter_converts_to_expected_string(self): formatter = DataFrameOutputFormatter(OutputFormat.TABLE) - output = formatter.get_formatted_output(self.test_df) - assert output == ( - "string_column int_column null_column\n" - " string1 42 \n" - " string2 43 " - ) - - def test_echo_formatted_dataframe_uses_pager_when_len_rows_gt_threshold_const( + output = list(formatter.get_formatted_output(self.test_df)) + assert "string_column" in output[0] + assert "int_column" in output[0] + assert "null_column" in output[0] + assert "string1" in output[1] + assert "42" in output[1] + assert "null" not in output[1] + assert "NaN" not in output[1] + assert "string2" in output[2] + assert "43" in output[2] + assert "null" not in output[2] + assert "NaN" not in output[2] + + def test_echo_formatted_dataframes_uses_pager_when_len_rows_gt_threshold_const( self, mocker ): mock_echo = mocker.patch("click.echo") @@ -830,7 +792,83 @@ def test_echo_formatted_dataframe_uses_pager_when_len_rows_gt_threshold_const( rows_len = output_formats_module.OUTPUT_VIA_PAGER_THRESHOLD + 1 big_df = DataFrame([{"column": val} for val in range(rows_len)]) small_df = DataFrame([{"column": val} for val in range(5)]) - formatter.echo_formatted_dataframe(big_df) - formatter.echo_formatted_dataframe(small_df) + formatter.echo_formatted_dataframes(big_df) + formatter.echo_formatted_dataframes(small_df) assert mock_echo.call_count == 1 assert mock_pager.call_count == 1 + + @pytest.mark.parametrize("fmt", OutputFormat.choices()) + def test_get_formatted_ouput_calls_checkpoint_func_on_every_row_in_df(self, fmt): + checkpointed = [] + + def checkpoint(event): + checkpointed.append(event["string_column"]) + + formatter = DataFrameOutputFormatter(fmt, checkpoint_func=checkpoint) + list(formatter.get_formatted_output(self.test_df)) + assert checkpointed == list(self.test_df.string_column.values) + + @pytest.mark.parametrize("fmt", OutputFormat.choices()) + def test_get_formatted_ouput_calls_checkpoint_func_on_every_row_in_df_when_checkpoint_key_not_in_column_list( + self, fmt + ): + checkpointed = [] + + def checkpoint(event): + checkpointed.append(event["string_column"]) + + formatter = DataFrameOutputFormatter(fmt, checkpoint_func=checkpoint) + list( + formatter.get_formatted_output( + self.test_df, columns=["int_column", "null_column"] + ) + ) + assert checkpointed == list(self.test_df.string_column.values) + + def test_iter_rows_calls_checkpoint_func_on_every_row_in_df(self): + checkpointed = [] + + def checkpoint(event): + checkpointed.append(event["string_column"]) + + formatter = DataFrameOutputFormatter(None, checkpoint_func=checkpoint) + list(formatter.iter_rows(self.test_df)) + assert checkpointed == list(self.test_df.string_column.values) + + @pytest.mark.parametrize("fmt", OutputFormat.choices()) + def test_echo_formatted_dataframes_prints_no_results_found_when_dataframes_empty( + self, fmt, capsys + ): + formatter = DataFrameOutputFormatter(fmt) + + def empty_results(): + yield DataFrame() + + formatter.echo_formatted_dataframes(empty_results()) + captured = capsys.readouterr() + assert "No results found." in captured.out + + +class TestFileEventsOutputFormatter: + test_df = DataFrame([AED_EVENT_DICT]) + + def test_format_when_none_passed_defaults_to_raw_json(self): + formatter = FileEventsOutputFormatter(output_format=None) + assert formatter.output_format == FileEventsOutputFormat.RAW + + def test_format_when_unknown_format_raises_CLI_error(self): + with pytest.raises(Code42CLIError): + FileEventsOutputFormatter("NOT_A_FORMAT") + + with pytest.raises(Code42CLIError): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.JSON) + formatter.output_format = "NOT_A_FORMAT" + list(formatter.get_formatted_output(self.test_df)) + + def test_CEF_formatter_converts_to_expected_string(self): + formatter = FileEventsOutputFormatter(FileEventsOutputFormat.CEF) + output = formatter.get_formatted_output(self.test_df) + assert ( + next(output) + == "CEF:0|Code42|Advanced Exfiltration Detection|1|C42203|READ_BY_APP|5|externalId=0_1d71796f-af5b-4231-9d8e-df6434da4663_912339407325443353_918253081700247636_16 end=1567996943851 rt=1568069262724 filePath=/Users/testtesterson/Downloads/About Downloads.lpdf/Contents/Resources/English.lproj/ fname=InfoPlist.strings fileType=UNCATEGORIZED fsize=86 fileHash=19b92e63beb08c27ab4489fcfefbbe44 fileCreateTime=1342923569000 fileModificationTime=1355886008000 suser=test.testerson+testair@example.com shost=Test's MacBook Air dvchost=192.168.0.3 src=71.34.4.22 deviceExternalId=912339407325443353 suid=912338501981077099 sourceServiceName=Endpoint reason=ApplicationRead spriv=testtesterson sproc=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\n" + ) diff --git a/tox.ini b/tox.ini index 8140d4a1b..771168ba1 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ deps = pytest == 4.6.11 pytest-mock == 2.0.0 pytest-cov == 2.10.0 - pandas == 1.1.3 + pandas >= 1.1.3 pexpect == 4.8.0 commands = From ae778727ba46494eaf7036b435d43c6977c9754e Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 6 Dec 2021 12:57:13 -0600 Subject: [PATCH 291/349] enable json deserialization of backup set data (#343) --- src/code42cli/cmds/devices.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 18530c544..82693be81 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -517,9 +517,9 @@ def handle_row(guid): "destinations": [ destination for destination in backup_set.destinations.values() ], - "included files": backup_set.included_files, - "excluded files": backup_set.excluded_files, - "filename exclusions": backup_set.filename_exclusions, + "included files": list(backup_set.included_files), + "excluded files": list(backup_set.excluded_files), + "filename exclusions": list(backup_set.filename_exclusions), "locked": backup_set.locked, } for backup_set in current_device_settings.backup_sets From bffc79890112ca5d9adb173539dd88ba30c39730 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 13 Dec 2021 11:35:21 -0600 Subject: [PATCH 292/349] prep for release (#345) * update changelog and bump version * add date to relase header * style --- CHANGELOG.md | 11 +++++++++-- src/code42cli/__version__.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 522d88c2f..16a9a6340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## 1.11.1 - 2021-11-09 +## 1.12.0 - 2021-12-13 + +### Fixed +- Bug where device settings were unable to be serialized to json. ### Added - `--columns` option to `security-data search` and `security-data send-to` commands which reduces output to only the specified colums/json keys. Accepts a comma-separated list of column names (case-insensitive). ### Changed -- Updated minimum version of py42 to `1.19.3` to provide access to updated URI paths for new standardized versioning scheme. - Improved accuracy of checkpointing for `security-data search` (checkpoints every row as it is printed to stdout instead of just the last event of the search response). +## 1.11.1 - 2021-11-09 + +### Changed +- Updated minimum version of py42 to `1.19.3` to provide access to updated URI paths for new standardized versioning scheme. + ## 1.11.0 - 2021-10-22 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index c3fa782ca..b518f6eed 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.11.1" +__version__ = "1.12.0" From 2ac4130763220716492274dc34cedfcb989ff224 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 14 Jan 2022 09:13:42 -0600 Subject: [PATCH 293/349] ignore additional exception args (#347) --- src/code42cli/click_ext/groups.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index 5273f2f79..afd218213 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -87,8 +87,9 @@ def invoke(self, ctx): Py42TrustedActivityInvalidCharacterError, Py42TrustedActivityIdNotFound, ) as err: - self.logger.log_error(err) - raise Code42CLIError(str(err)) + msg = err.args[0] + self.logger.log_error(msg) + raise Code42CLIError(msg) except Py42ForbiddenError as err: self.logger.log_verbose_error(self._original_args, err.response.request) From d68dce38776b823419465991b7c0515d842477f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Jan 2022 15:03:02 -0600 Subject: [PATCH 294/349] Bump ipython from 7.16.1 to 7.16.3 (#348) Bumps [ipython](https://github.com/ipython/ipython) from 7.16.1 to 7.16.3. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/7.16.1...7.16.3) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6684d30e7..12a1f4e2b 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ "colorama>=0.4.3", "keyring==18.0.1", "keyrings.alt==3.2.0", - "ipython==7.16.1", + "ipython==7.16.3", "pandas>=1.1.3", "py42>=1.19.3", ], From 137487546dc5655f4545da6f69d11580e22ea88e Mon Sep 17 00:00:00 2001 From: Alan Grgic Date: Fri, 21 Jan 2022 15:21:13 -0600 Subject: [PATCH 295/349] prep 1.12.1 (#349) --- CHANGELOG.md | 5 +++++ src/code42cli/__version__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a9a6340..e1f22e4b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.12.1 - 2022-01-21 + +### Fixed + +- Vulnerability in `ipython` dependency. See [CVE-2022-21699](https://nvd.nist.gov/vuln/detail/CVE-2022-21699). ## 1.12.0 - 2021-12-13 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index b518f6eed..438a38d1e 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.12.0" +__version__ = "1.12.1" From 91f52895cc8a4550b159ec615ff98ffb2e476ba7 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 1 Feb 2022 15:06:56 -0600 Subject: [PATCH 296/349] add device rename and bulk rename cmds (#350) * add device rename and bulk rename cmds * move error checking into helper method so it applies to bulk cmd as well * move error checking into helper method so that it applies to bulk method * make forbidden error more specific --- src/code42cli/cmds/devices.py | 62 +++++++++++++++++++- tests/cmds/test_devices.py | 105 ++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 82693be81..2b9525d59 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -38,6 +38,10 @@ def devices(state): "device-guid", type=str, callback=lambda ctx, param, arg: _verify_guid_type(arg), ) +new_device_name_option = click.option( + "-n", "--new-device-name", help="The new name for the device.", required=True +) + def change_device_name_option(help_msg): return click.option( @@ -60,6 +64,15 @@ def change_device_name_option(help_msg): ) +@devices.command() +@device_guid_argument +@new_device_name_option +@sdk_options() +def rename(state, device_guid, new_device_name): + """Rename a device with Code42. Requires the device GUID to rename.""" + _change_device_name(state.sdk, device_guid, new_device_name) + + @devices.command() @device_guid_argument @change_device_name_option( @@ -144,9 +157,16 @@ def _update_cold_storage_purge_date(sdk, guid, purge_date): def _change_device_name(sdk, guid, name): - device_settings = sdk.devices.get_settings(guid) - device_settings.name = name - sdk.devices.update_settings(device_settings) + try: + device_settings = sdk.devices.get_settings(guid) + device_settings.name = name + sdk.devices.update_settings(device_settings) + except exceptions.Py42ForbiddenError: + raise Code42CLIError( + f"You don't have the necessary permissions to rename the device with GUID '{guid}'." + ) + except exceptions.Py42NotFoundError: + raise Code42CLIError(f"The device with GUID '{guid}' was not found.") @devices.command() @@ -544,6 +564,7 @@ def bulk(state): _bulk_device_activation_headers = ["guid"] +_bulk_device_rename_headers = ["guid", "name"] devices_generate_template = generate_template_cmd_factory( @@ -551,6 +572,7 @@ def bulk(state): commands_dict={ "reactivate": _bulk_device_activation_headers, "deactivate": _bulk_device_activation_headers, + "rename": _bulk_device_rename_headers, }, help_message="Generate the CSV template needed for bulk device commands.", ) @@ -632,3 +654,37 @@ def handle_row(**row): raise_global_error=False, ) formatter.echo_formatted_list(result_rows) + + +@bulk.command(name="rename") +@read_csv_arg(headers=_bulk_device_rename_headers) +@format_option +@sdk_options() +def bulk_rename(state, csv_rows, format): + """Rename all devices from the provided CSV containing a 'guid' and a 'name' column.""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + + csv_rows[0]["renamed"] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) + + def handle_row(**row): + try: + _change_device_name(sdk, row["guid"], row["name"]) + row["renamed"] = "True" + except Exception as err: + row["renamed"] = f"False: {err}" + stats.increment_total_errors() + return row + + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Renaming devices:", + stats=stats, + raise_global_error=False, + ) + formatter.echo_formatted_list(result_rows) diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 630f9cd27..a5780faf4 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -22,6 +22,7 @@ from code42cli.worker import WorkerStats _NAMESPACE = "code42cli.cmds.devices" +TEST_NEW_DEVICE_NAME = "test-new-device-name-123" TEST_DATE_OLDER = "2020-01-01T12:00:00.774Z" TEST_DATE_NEWER = "2021-01-01T12:00:00.774Z" TEST_DATE_MIDDLE = "2020-06-01T12:00:00" @@ -468,6 +469,61 @@ def worker_stats(mocker, worker_stats_factory): return stats +def test_rename_calls_get_and_update_settings_with_expected_params(runner, cli_state): + cli_state.sdk.devices.get_settings.return_value = mock_device_settings + runner.invoke( + cli, + [ + "devices", + "rename", + TEST_DEVICE_GUID, + "--new-device-name", + TEST_NEW_DEVICE_NAME, + ], + obj=cli_state, + ) + cli_state.sdk.devices.get_settings.assert_called_once_with(TEST_DEVICE_GUID) + cli_state.sdk.devices.update_settings.assert_called_once_with(mock_device_settings) + + +def test_rename_when_missing_guid_prints_error(runner, cli_state): + result = runner.invoke( + cli, ["devices", "rename", "-n", TEST_NEW_DEVICE_NAME], obj=cli_state + ) + assert result.exit_code == 2 + assert "Missing argument 'DEVICE_GUID'" in result.output + + +def test_rename_when_missing_name_prints_error(runner, cli_state): + result = runner.invoke(cli, ["devices", "rename", TEST_DEVICE_GUID], obj=cli_state) + assert result.exit_code == 2 + assert "Missing option '-n' / '--new-device-name'" in result.output + + +def test_rename_when_guid_not_found_py42_raises_exception_prints_error( + runner, cli_state, custom_error +): + cli_state.sdk.devices.get_settings.side_effect = Py42NotFoundError(custom_error) + + result = runner.invoke( + cli, + [ + "devices", + "rename", + TEST_DEVICE_GUID, + "--new-device-name", + TEST_NEW_DEVICE_NAME, + ], + obj=cli_state, + ) + cli_state.sdk.devices.get_settings.assert_called_once_with(TEST_DEVICE_GUID) + assert result.exit_code == 1 + assert ( + f"Error: The device with GUID '{TEST_DEVICE_GUID}' was not found." + in result.output + ) + + def test_deactivate_deactivates_device( runner, cli_state, deactivate_device_success, get_device_by_guid_success ): @@ -983,3 +1039,52 @@ def _get(guid): handler(guid="test") handler(guid="not test") assert worker_stats.increment_total_errors.call_count == 1 + + +def test_bulk_rename_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_rename.csv", "w") as csv: + csv.writelines(["guid,name\n", "test-guid,test-name\n"]) + runner.invoke( + cli, ["devices", "bulk", "rename", "test_bulk_rename.csv"], obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"guid": "test-guid", "name": "test-name", "renamed": "False"} + ] + + +def test_bulk_rename_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_rename.csv", "w") as csv: + csv.writelines(["guid,name\n", "\n", "test-guid,test-name\n\n"]) + runner.invoke( + cli, ["devices", "bulk", "rename", "test_bulk_rename.csv"], obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"guid": "test-guid", "name": "test-name", "renamed": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_rename_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats +): + def _get(guid): + if guid == "test": + raise Exception("TEST") + return create_mock_response(mocker, data=TEST_DEVICE_RESPONSE) + + cli_state.sdk.devices.get_settings = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_rename.csv", "w") as csv: + csv.writelines(["guid,name\n", "1,2\n"]) + runner.invoke( + cli, ["devices", "bulk", "rename", "test_bulk_rename.csv"], obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler(guid="test", name="test-name-1") + handler(guid="not test", name="test-name-2") + assert worker_stats.increment_total_errors.call_count == 1 From d16ded8aaef0a1b80ffe6174e3b8d315c29930f7 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Fri, 4 Feb 2022 09:35:14 -0600 Subject: [PATCH 297/349] update py42 branch (#351) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 771168ba1..ceb8cfad2 100644 --- a/tox.ini +++ b/tox.ini @@ -44,7 +44,7 @@ deps = pytest == 4.6.11 pytest-mock == 2.0.0 pytest-cov == 2.10.0 - git+https://github.com/code42/py42.git@master#egg=py42 + git+https://github.com/code42/py42.git@main#egg=py42 [testenv:integration] commands = From 14aac5d6a0dc7d30d607e6622be2e6ea66875371 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Mon, 7 Feb 2022 09:20:40 -0600 Subject: [PATCH 298/349] Chore/allow optional cols for bulk cmds (#352) * switching flat file bulk commands to use csv_args * test for flat file compatability * update file_reader * strip commented header line from files * update user guide * remove flat file methods * typo --- docs/guides.md | 1 + docs/userguides/bulkcommands.md | 23 ++++++++ src/code42cli/bulk.py | 25 ++------- src/code42cli/cmds/departing_employee.py | 19 ++++--- src/code42cli/cmds/devices.py | 1 - src/code42cli/cmds/high_risk_employee.py | 13 +++-- src/code42cli/file_readers.py | 24 +++------ tests/cmds/test_departing_employee.py | 69 ++++++++++++++++++++++-- tests/cmds/test_devices.py | 39 ++++++++++++++ tests/cmds/test_high_risk_employee.py | 66 +++++++++++++++++++++-- tests/cmds/test_trustedactivities.py | 4 +- tests/test_bulk.py | 21 ++------ 12 files changed, 225 insertions(+), 80 deletions(-) create mode 100644 docs/userguides/bulkcommands.md diff --git a/docs/guides.md b/docs/guides.md index 3bc4f3df8..5fb3739b7 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -11,3 +11,4 @@ * [Configure Trusted Activities](userguides/trustedactivities.md) * [Configure Alert Rules](userguides/alertrules.md) * [Add and Manage Cases](userguides/cases.md) +* [Use Bulk Commands](userguides/bulkcommands.md) diff --git a/docs/userguides/bulkcommands.md b/docs/userguides/bulkcommands.md new file mode 100644 index 000000000..09712a3cd --- /dev/null +++ b/docs/userguides/bulkcommands.md @@ -0,0 +1,23 @@ +# Using Bulk Commands + +Bulk functionality is available for many Code42 CLI methods, more details on which commands have bulk capabilities can be found in the [Commands Documentation](../commands.md). + +All bulk methods take a CSV file as input. + +The `generate-template` command can be used to create a CSV file with the necessary headers for a particular command. + +For instance, the following command will create a file named `devices_bulk_deactivate.csv` with a single column header row of `guid`. +```bash +code42 devices bulk generate-template deactivate +``` + +The CSV file can contain more columns than are necessary for the command, however then the header row is **required**. + +If the CSV file contains the *exact* number of columns that are necessary for the command then the header row is **optional**, but columns are expected to be in the same order as the template. + +To run a bulk method, simply pass the CSV file path to the desired command. For example, you would use to following command to deactivate multiple devices within your organization at once: + + +```bash +code42 devices bulk deactivate devices_bulk_deactivate.csv +``` diff --git a/src/code42cli/bulk.py b/src/code42cli/bulk.py index db17336c9..72cbe4754 100644 --- a/src/code42cli/bulk.py +++ b/src/code42cli/bulk.py @@ -17,14 +17,9 @@ def __iter__(self): return iter([self.ADD, self.REMOVE]) -def write_template_file(path, columns=None, flat_item=None): +def write_template_file(path, columns): with open(path, "w", encoding="utf8") as new_file: - if columns: - new_file.write(",".join(columns)) - else: - new_file.write( - f"# This template takes a single {flat_item or 'item'} to be processed on each row." - ) + new_file.write(",".join(columns)) def generate_template_cmd_factory(group_name, commands_dict, help_message=None): @@ -58,10 +53,7 @@ def generate_template(cmd, path): if not path: filename = f"{group_name}_bulk_{cmd.replace('-', '_')}.csv" path = os.path.join(os.getcwd(), filename) - if isinstance(columns, str): - write_template_file(path, columns=None, flat_item=columns) - else: - write_template_file(path, columns=columns) + write_template_file(path, columns) return generate_template @@ -148,10 +140,7 @@ def run(self): return self._stats._results def _process_row(self, row): - if isinstance(row, dict): - self._process_csv_row(row) - elif row: - self._process_flat_file_row(row.strip()) + self._process_csv_row(row) def _process_csv_row(self, row): # Removes problems from including extra columns. Error messages from out of order args @@ -163,12 +152,6 @@ def _process_csv_row(self, row): lambda *args, **kwargs: self._handle_row(*args, **kwargs), **row_values ) - def _process_flat_file_row(self, row): - if row: - self.__worker.do_async( - lambda *args, **kwargs: self._handle_row(*args, **kwargs), row - ) - def _handle_row(self, *args, **kwargs): return self._row_handler(*args, **kwargs) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 9ff558a66..ace6b9854 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -15,7 +15,6 @@ from code42cli.cmds.shared import get_user_id from code42cli.errors import Code42CLIError from code42cli.file_readers import read_csv_arg -from code42cli.file_readers import read_flat_file_arg from code42cli.options import format_option from code42cli.options import sdk_options @@ -86,16 +85,21 @@ def bulk(state): DEPARTING_EMPLOYEE_CSV_HEADERS = ["username", "cloud_alias", "departure_date", "notes"] +REMOVE_EMPLOYEE_HEADERS = ["username"] + departing_employee_generate_template = generate_template_cmd_factory( group_name="departing_employee", - commands_dict={"add": DEPARTING_EMPLOYEE_CSV_HEADERS, "remove": "username"}, + commands_dict={ + "add": DEPARTING_EMPLOYEE_CSV_HEADERS, + "remove": REMOVE_EMPLOYEE_HEADERS, + }, ) bulk.add_command(departing_employee_generate_template) @bulk.command( name="add", - help="Bulk add users to the Departing Employees detection list using a CSV file with " + help="Bulk add users to the departing employees detection list using a CSV file with " f"format: {','.join(DEPARTING_EMPLOYEE_CSV_HEADERS)}.", ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @@ -125,12 +129,11 @@ def handle_row(username, cloud_alias, departure_date, notes): @bulk.command( name="remove", - help="Bulk remove users from the Departing Employees detection list using a line-separated " - "file of usernames.", + help=f"Bulk remove users from the departing employees detection list using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.", ) -@read_flat_file_arg +@read_csv_arg(headers=REMOVE_EMPLOYEE_HEADERS) @sdk_options() -def bulk_remove(state, file_rows): +def bulk_remove(state, csv_rows): sdk = state.sdk def handle_row(username): @@ -138,7 +141,7 @@ def handle_row(username): run_bulk_process( handle_row, - file_rows, + csv_rows, progress_label="Removing users from the Departing Employees detection list:", ) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 2b9525d59..ee81ca9cd 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -566,7 +566,6 @@ def bulk(state): _bulk_device_activation_headers = ["guid"] _bulk_device_rename_headers = ["guid", "name"] - devices_generate_template = generate_template_cmd_factory( group_name="devices", commands_dict={ diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 620ef2621..d31f5e7b4 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -18,7 +18,6 @@ from code42cli.cmds.detectionlists.options import username_arg from code42cli.cmds.shared import get_user_id from code42cli.file_readers import read_csv_arg -from code42cli.file_readers import read_flat_file_arg from code42cli.options import format_option from code42cli.options import sdk_options @@ -110,12 +109,13 @@ def bulk(state): HIGH_RISK_EMPLOYEE_CSV_HEADERS = ["username", "cloud_alias", "risk_tag", "notes"] RISK_TAG_CSV_HEADERS = ["username", "tag"] +REMOVE_EMPLOYEE_HEADERS = ["username"] high_risk_employee_generate_template = generate_template_cmd_factory( group_name="high_risk_employee", commands_dict={ "add": HIGH_RISK_EMPLOYEE_CSV_HEADERS, - "remove": "username", + "remove": REMOVE_EMPLOYEE_HEADERS, "add-risk-tags": RISK_TAG_CSV_HEADERS, "remove-risk-tags": RISK_TAG_CSV_HEADERS, }, @@ -145,12 +145,11 @@ def handle_row(username, cloud_alias, risk_tag, notes): @bulk.command( name="remove", - help="Bulk remove users from the high risk employees detection list using a line-separated file " - "of usernames.", + help=f"Bulk remove users from the high risk employees detection list using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.", ) -@read_flat_file_arg +@read_csv_arg(headers=REMOVE_EMPLOYEE_HEADERS) @sdk_options() -def bulk_remove(state, file_rows): +def bulk_remove(state, csv_rows): sdk = state.sdk def handle_row(username): @@ -158,7 +157,7 @@ def handle_row(username): run_bulk_process( handle_row, - file_rows, + csv_rows, progress_label="Removing users from high risk employee detection list:", ) diff --git a/src/code42cli/file_readers.py b/src/code42cli/file_readers.py index 2a0e1c92f..87a2845d7 100644 --- a/src/code42cli/file_readers.py +++ b/src/code42cli/file_readers.py @@ -28,6 +28,12 @@ def read_csv(file, headers): else error is raised. """ lines = file.readlines() + + # check if header is commented for flat-file backwards compatability + if lines[0].startswith("#"): + # strip comment line + lines.pop(0) + first_line = lines[0].strip().split(",") # handle when first row has all of our expected headers @@ -52,21 +58,3 @@ def read_csv(file, headers): else: missing = [field for field in headers if field not in first_line] raise Code42CLIError(f"Missing required columns in csv: {missing}") - - -def read_flat_file(file): - """Helper to read rows of a flat file, automatically removing header comment row if - it exists, and strips whitespace from each row automatically.""" - first_row = next(file) - if first_row.startswith("#"): - return [row.strip() for row in file] - else: - return [first_row.strip(), *[row.strip() for row in file]] - - -read_flat_file_arg = click.argument( - "file_rows", - type=AutoDecodedFile("r"), - metavar="FILE", - callback=lambda ctx, param, arg: read_flat_file(arg), -) diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index d0323bff4..8b67f6a7f 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -277,13 +277,76 @@ def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state_wit bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: - csv.writelines(["# username\n", "test_user1\n", "test_user2\n"]) + csv.writelines(["username\n", "test_user1\n", "test_user2\n"]) + runner.invoke( + cli, + ["departing-employee", "bulk", "remove", "test_remove.csv"], + obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": "test_user1"}, + {"username": "test_user2"}, + ] + + +def test_remove_bulk_users_uses_expected_arguments_when_no_header( + runner, mocker, cli_state_with_user +): + bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines(["test_user1\n", "test_user2\n"]) + runner.invoke( + cli, + ["departing-employee", "bulk", "remove", "test_remove.csv"], + obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": "test_user1"}, + {"username": "test_user2"}, + ] + + +def test_remove_bulk_users_uses_expected_arguments_when_extra_columns( + runner, mocker, cli_state_with_user +): + bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines( + [ + "username,test_column\n", + "test_user1,test_value1\n", + "test_user2,test_value2\n", + ] + ) runner.invoke( cli, ["departing-employee", "bulk", "remove", "test_remove.csv"], obj=cli_state_with_user, ) - assert bulk_processor.call_args[0][1] == ["test_user1", "test_user2"] + assert bulk_processor.call_args[0][1] == [ + {"username": "test_user1"}, + {"username": "test_user2"}, + ] + + +def test_remove_bulk_users_uses_expected_arguments_when_flat_file( + runner, mocker, cli_state_with_user +): + bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.txt", "w") as csv: + csv.writelines(["# username\n", "test_user1\n", "test_user2\n"]) + runner.invoke( + cli, + ["departing-employee", "bulk", "remove", "test_remove.txt"], + obj=cli_state_with_user, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": "test_user1"}, + {"username": "test_user2"}, + ] def test_add_departing_employee_when_invalid_date_validation_raises_error( @@ -350,7 +413,7 @@ def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( (f"{DEPARTING_EMPLOYEE_COMMAND} add", "Missing argument 'USERNAME'."), (f"{DEPARTING_EMPLOYEE_COMMAND} remove", "Missing argument 'USERNAME'.",), (f"{DEPARTING_EMPLOYEE_COMMAND} bulk add", "Missing argument 'CSV_FILE'.",), - (f"{DEPARTING_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'FILE'.",), + (f"{DEPARTING_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'CSV_FILE'.",), ], ) def test_departing_employee_command_when_missing_required_parameters_returns_error( diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index a5780faf4..fe9b13a55 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -942,6 +942,28 @@ def test_bulk_deactivate_uses_expected_arguments(runner, mocker, cli_state): ] +def test_bulk_deactivate_uses_expected_arguments_when_no_header( + runner, mocker, cli_state +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_deactivate.csv", "w") as csv: + csv.writelines(["test_guid1\n"]) + runner.invoke( + cli, + ["devices", "bulk", "deactivate", "test_bulk_deactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + { + "guid": "test_guid1", + "deactivated": "False", + "change_device_name": False, + "purge_date": None, + } + ] + + def test_bulk_deactivate_ignores_blank_lines(runner, mocker, cli_state): bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): @@ -1001,6 +1023,23 @@ def test_bulk_reactivate_uses_expected_arguments(runner, mocker, cli_state): assert bulk_processor.call_args[0][1] == [{"guid": "test", "reactivated": "False"}] +def test_bulk_reactivate_uses_expected_arguments_when_no_header( + runner, mocker, cli_state +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_bulk_reactivate.csv", "w") as csv: + csv.writelines(["test_guid1\n"]) + runner.invoke( + cli, + ["devices", "bulk", "reactivate", "test_bulk_reactivate.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"guid": "test_guid1", "reactivated": "False"}, + ] + + def test_bulk_reactivate_ignores_blank_lines(runner, mocker, cli_state): bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 8b8bad199..5ddf60fdc 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -289,15 +289,75 @@ def test_bulk_remove_employees_uses_expected_arguments(runner, cli_state, mocker bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: + csv.writelines(["username\n", "test@example.com\n", "test2@example.com"]) + runner.invoke( + cli, + ["high-risk-employee", "bulk", "remove", "test_remove.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": "test@example.com"}, + {"username": "test2@example.com"}, + ] + + +def test_bulk_remove_employees_uses_expected_arguments_when_extra_columns( + runner, cli_state, mocker +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines( + [ + "username,test_column\n", + "test@example.com,test_value1\n", + "test2@example.com,test_value2\n", + ] + ) + runner.invoke( + cli, + ["high-risk-employee", "bulk", "remove", "test_remove.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": "test@example.com"}, + {"username": "test2@example.com"}, + ] + + +def test_bulk_remove_employees_uses_expected_arguments_when_flat_file( + runner, cli_state, mocker +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.txt", "w") as csv: csv.writelines(["# username\n", "test@example.com\n", "test2@example.com"]) + runner.invoke( + cli, + ["high-risk-employee", "bulk", "remove", "test_remove.txt"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": "test@example.com"}, + {"username": "test2@example.com"}, + ] + + +def test_bulk_remove_employees_uses_expected_arguments_when_no_header( + runner, cli_state, mocker +): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove.csv", "w") as csv: + csv.writelines(["test@example.com\n", "test2@example.com"]) runner.invoke( cli, ["high-risk-employee", "bulk", "remove", "test_remove.csv"], obj=cli_state, ) assert bulk_processor.call_args[0][1] == [ - "test@example.com", - "test2@example.com", + {"username": "test@example.com"}, + {"username": "test2@example.com"}, ] @@ -364,7 +424,7 @@ def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( (f"{HR_EMPLOYEE_COMMAND} add", "Missing argument 'USERNAME'."), (f"{HR_EMPLOYEE_COMMAND} remove", "Missing argument 'USERNAME'."), (f"{HR_EMPLOYEE_COMMAND} bulk add", "Missing argument 'CSV_FILE'."), - (f"{HR_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'FILE'."), + (f"{HR_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'CSV_FILE'."), (f"{HR_EMPLOYEE_COMMAND} bulk add-risk-tags", "Missing argument 'CSV_FILE'."), ( f"{HR_EMPLOYEE_COMMAND} bulk remove-risk-tags", diff --git a/tests/cmds/test_trustedactivities.py b/tests/cmds/test_trustedactivities.py index ff96e80f2..c6177127d 100644 --- a/tests/cmds/test_trustedactivities.py +++ b/tests/cmds/test_trustedactivities.py @@ -365,13 +365,13 @@ def test_bulk_update_trusted_activities_uses_expected_arguments( ] -def test_bulk_remove_trusted_activities_uses_expected_arguments( +def test_bulk_remove_trusted_activities_uses_expected_arguments_when_no_header( runner, mocker, cli_state_with_user ): bulk_processor = mocker.patch("code42cli.cmds.trustedactivities.run_bulk_process") with runner.isolated_filesystem(): with open("test_remove.csv", "w") as csv: - csv.writelines(["resource_id\n", "1\n", "2\n"]) + csv.writelines(["1\n", "2\n"]) command = ["trusted-activities", "bulk", "remove", "test_remove.csv"] runner.invoke( cli, command, obj=cli_state_with_user, diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 51730995c..453863973 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -128,25 +128,12 @@ def func_for_bulk(test1): processor.run() assert processed_rows == [1] - def test_run_when_reader_returns_strs_processes_strs(self): - processed_rows = [] - - def func_for_bulk(test): - processed_rows.append(test) - - rows = ["row1", "row2", "row3"] - processor = BulkProcessor(func_for_bulk, rows) - processor.run() - assert "row1" in processed_rows - assert "row2" in processed_rows - assert "row3" in processed_rows - def test_run_when_error_occurs_raises_expected_logged_cli_error(self): def func_for_bulk(test): if test == "row2": raise Exception() - rows = ["row1", "row2", "row3"] + rows = [{"test": "row1"}, {"test": "row2"}, {"test": "row3"}] with pytest.raises(errors.LoggedCLIError) as err: processor = BulkProcessor(func_for_bulk, rows) processor.run() @@ -157,7 +144,7 @@ def test_run_when_no_errors_occur_does_not_print_error_message(self, capsys): def func_for_bulk(test): pass - rows = ["row1", "row2", "row3"] + rows = [{"test": "row1"}, {"test": "row2"}, {"test": "row3"}] processor = BulkProcessor(func_for_bulk, rows) processor.run() @@ -170,7 +157,7 @@ def test_run_when_row_is_endline_does_not_process_row(self): def func_for_bulk(test): processed_rows.append(test) - rows = ["row1", "row2", "\n"] + rows = [{"test": "row1"}, {"test": "row2"}, {"test": "\n"}] processor = BulkProcessor(func_for_bulk, rows) processor.run() @@ -196,7 +183,7 @@ def test_processor_stores_results_in_stats(self,): def func_for_bulk(test): return test - rows = ["row1", "row2", "row3"] + rows = [{"test": "row1"}, {"test": "row2"}, {"test": "row3"}] processor = BulkProcessor(func_for_bulk, rows) processor.run() assert "row1" in processor._stats.results From 1e9db876aac9bbd669f5bfd0ed67ecbbba84de1a Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 15 Feb 2022 16:20:10 -0600 Subject: [PATCH 299/349] add sleep (#356) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d381040b..a5e93ec38 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,4 +57,4 @@ jobs: - name: Start up the mock servers run: cd code42-mock-servers; docker-compose up -d --build - name: Run the integration tests - run: cd code42cli; tox -e integration + run: sleep 15; cd code42cli; tox -e integration From 9bbb119486415be7188f04f663f78fdcd4e3f036 Mon Sep 17 00:00:00 2001 From: c42tschwandt <43589715+c42tschwandt@users.noreply.github.com> Date: Thu, 17 Feb 2022 10:43:05 -0600 Subject: [PATCH 300/349] Update SIEM example (#354) Added a clarification for CEF formats, as that mapping is contained within the CLI program itself Co-authored-by: Tora Kozic --- docs/userguides/siemexample.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index 4413dd21d..5384525ad 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -8,6 +8,8 @@ into a security information and event management (SIEM) tool like LogRhythm, Sum To ingest file events or alerts into a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration must be assigned roles that provide the necessary permissions. +The CEF format is not recommended because it was not designed for insider risk event data. Code42 file event data contains many fields that provide valuable insider risk context that have no CEF equivalent. However, if you need to use CEF, the JSON-to-CEF mapping at the bottom of this document indicates which fields are included and how the field names map to other formats. + ## Before you begin First install and configure the Code42 CLI following the instructions in From 2546ac0acaa177026ccae54b374fd938b0ea6f7a Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Tue, 22 Feb 2022 14:10:08 -0600 Subject: [PATCH 301/349] implement user alias management (#355) * implement user alias management * add exception handling for too long alias and for nonexistent user * update error handling to correctly report on too many aliases, and also to prevent adding an alias that is too long. * adding cmd 'list-aliases' and moving error handling into py42 * add empty list test for list-aliases Co-authored-by: Tora Kozic --- CHANGELOG.md | 5 + setup.py | 2 +- src/code42cli/click_ext/groups.py | 4 + src/code42cli/cmds/users.py | 134 +++++++++++++ tests/cmds/test_users.py | 302 ++++++++++++++++++++++++++++++ 5 files changed, 446 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f22e4b9..456ed0a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added +- `users add-alias`, `users remove-alias`, `users bulk add-alias`, and `users bulk remove-alias` for managing cloud aliases for users. + ## 1.12.1 - 2022-01-21 ### Fixed diff --git a/setup.py b/setup.py index 12a1f4e2b..092cf7b21 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython==7.16.3", "pandas>=1.1.3", - "py42>=1.19.3", + "py42>=1.21.1", ], extras_require={ "dev": [ diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index afd218213..6f81ac67f 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -7,6 +7,8 @@ from py42.exceptions import Py42ActiveLegalHoldError from py42.exceptions import Py42CaseAlreadyHasEventError from py42.exceptions import Py42CaseNameExistsError +from py42.exceptions import Py42CloudAliasCharacterLimitExceededError +from py42.exceptions import Py42CloudAliasLimitExceededError from py42.exceptions import Py42DescriptionLimitExceededError from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42HTTPError @@ -86,6 +88,8 @@ def invoke(self, ctx): Py42TrustedActivityConflictError, Py42TrustedActivityInvalidCharacterError, Py42TrustedActivityIdNotFound, + Py42CloudAliasLimitExceededError, + Py42CloudAliasCharacterLimitExceededError, ) as err: msg = err.args[0] self.logger.log_error(msg) diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 39a62333f..8341afe12 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -3,6 +3,7 @@ import click from pandas import DataFrame from pandas import json_normalize +from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42NotFoundError from code42cli.bulk import generate_template_cmd_factory @@ -215,6 +216,8 @@ def reactivate(state, username): _bulk_user_roles_headers = ["username", "role_name"] +_bulk_user_alias_headers = ["username", "alias"] + @users.command(name="move") @username_option("The username of the user to move.", required=True) @@ -226,6 +229,37 @@ def change_organization(state, username, org_id): _change_organization(state.sdk, username, org_id) +@users.command() +@click.argument("username") +@click.argument("alias") +@sdk_options() +def add_alias(state, username, alias): + """Add a cloud alias for a given user.""" + _add_cloud_alias(state.sdk, username, alias) + + +@users.command() +@click.argument("username") +@click.argument("alias") +@sdk_options() +def remove_alias(state, username, alias): + """Remove a cloud alias for a given user.""" + _remove_cloud_alias(state.sdk, username, alias) + + +@users.command() +@click.argument("username") +@sdk_options() +def list_aliases(state, username): + """List the cloud aliases for a given user.""" + user = _get_user(state.sdk, username) + aliases = user["cloudUsernames"] + if aliases: + click.echo(aliases) + else: + click.echo(f"No cloud aliases for user '{username}' found.") + + @users.group(cls=OrderedGroup) @sdk_options(hidden=True) def orgs(state): @@ -292,6 +326,8 @@ def bulk(state): commands_dict={ "update": _bulk_user_update_headers, "move": _bulk_user_move_headers, + "add-alias": _bulk_user_alias_headers, + "remove-alias": _bulk_user_alias_headers, }, help_message="Generate the CSV template needed for bulk user commands.", ) @@ -539,6 +575,86 @@ def handle_row(**row): formatter.echo_formatted_list(result_rows) +@bulk.command( + name="add-alias", + help=f"Add aliases to a list of users from the provided CSV in format: {','.join(_bulk_user_alias_headers)}", +) +@read_csv_arg(headers=_bulk_user_alias_headers) +@format_option +@sdk_options() +def bulk_add_alias(state, csv_rows, format): + """Bulk add aliases to users""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + success_header = "alias added" + + csv_rows[0][success_header] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) + + def handle_row(**row): + try: + _add_cloud_alias( + sdk, **{key: row[key] for key in row.keys() if key != success_header} + ) + row[success_header] = "True" + except Exception as err: + row[success_header] = f"False: {err}" + stats.increment_total_errors() + return row + + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Adding aliases to users:", + stats=stats, + raise_global_error=False, + ) + formatter.echo_formatted_list(result_rows) + + +@bulk.command( + name="remove-alias", + help=f"Remove aliases from a list of users from the provided CSV in format: {','.join(_bulk_user_alias_headers)}", +) +@read_csv_arg(headers=_bulk_user_alias_headers) +@format_option +@sdk_options() +def bulk_remove_alias(state, csv_rows, format): + """Bulk remove aliases from users""" + + # Initialize the SDK before starting any bulk processes + # to prevent multiple instances and having to enter 2fa multiple times. + sdk = state.sdk + success_header = "alias removed" + + csv_rows[0][success_header] = "False" + formatter = OutputFormatter(format, {key: key for key in csv_rows[0].keys()}) + stats = create_worker_stats(len(csv_rows)) + + def handle_row(**row): + try: + _remove_cloud_alias( + sdk, **{key: row[key] for key in row.keys() if key != success_header} + ) + row[success_header] = "True" + except Exception as err: + row[success_header] = f"False: {err}" + stats.increment_total_errors() + return row + + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Removing aliases from users:", + stats=stats, + raise_global_error=False, + ) + formatter.echo_formatted_list(result_rows) + + def _add_user_role(sdk, username, role_name): user_id = _get_legacy_user_id(sdk, username) _get_role_id(sdk, role_name) # function provides role name validation @@ -666,3 +782,21 @@ def _deactivate_user(sdk, username): def _reactivate_user(sdk, username): user_id = _get_legacy_user_id(sdk, username) sdk.users.reactivate(user_id) + + +def _get_user(sdk, username): + # use when retrieving the user information from the detectionlists module + try: + return sdk.detectionlists.get_user(username).data + except Py42BadRequestError: + raise UserDoesNotExistError(username) + + +def _add_cloud_alias(sdk, username, alias): + user = _get_user(sdk, username) + sdk.detectionlists.add_user_cloud_alias(user["userId"], alias) + + +def _remove_cloud_alias(sdk, username, alias): + user = _get_user(sdk, username) + sdk.detectionlists.remove_user_cloud_alias(user["userId"], alias) diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 9e8d1d5a7..28151e34c 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -2,6 +2,9 @@ import pytest from py42.exceptions import Py42ActiveLegalHoldError +from py42.exceptions import Py42BadRequestError +from py42.exceptions import Py42CloudAliasCharacterLimitExceededError +from py42.exceptions import Py42CloudAliasLimitExceededError from py42.exceptions import Py42InvalidEmailError from py42.exceptions import Py42InvalidPasswordError from py42.exceptions import Py42InvalidUsernameError @@ -40,6 +43,25 @@ } ] } +TEST_USER_RESPONSE = { + "tenantId": "SampleTenant1", + "userId": 12345, + "userName": "Sample.User1@samplecase.com", + "displayName": "Sample User1", + "notes": "This is an example of notes about Sample User1.", + "cloudUsernames": ["Sample.User1@samplecase.com", "Sample.User1@gmail.com"], + "managerUid": 12345, + "managerUsername": "manager.user1@samplecase.com", + "managerDisplayName": "Manager Name", + "title": "Software Engineer", + "division": "Engineering", + "department": "Research and Development", + "employmentType": "Full-time", + "city": "Anytown", + "state": "MN", + "country": "US", + "riskFactors": ["FLIGHT_RISK", "HIGH_IMPACT_EMPLOYEE"], +} TEST_MATTER_RESPONSE = { "legalHolds": [ {"legalHoldUid": "123456789", "name": "Legal Hold #1", "active": True}, @@ -78,7 +100,9 @@ TEST_EMPTY_MATTERS_RESPONSE = {"legalHolds": []} TEST_EMPTY_USERS_RESPONSE = {"users": []} TEST_USERNAME = TEST_USERS_RESPONSE["users"][0]["username"] +TEST_ALIAS = TEST_USER_RESPONSE["cloudUsernames"][0] TEST_USER_ID = TEST_USERS_RESPONSE["users"][0]["userId"] +TEST_USER_UID = TEST_USER_RESPONSE["userId"] TEST_ROLE_NAME = TEST_ROLE_RETURN_DATA["data"][0]["roleName"] TEST_GET_ORG_RESPONSE = { "orgId": 9087, @@ -128,6 +152,19 @@ def get_users_response(mocker): return create_mock_response(mocker, data=TEST_USERS_RESPONSE) +@pytest.fixture +def get_user_response(mocker): + return create_mock_response(mocker, data=TEST_USER_RESPONSE) + + +@pytest.fixture +def get_user_failure(mocker): + return Py42BadRequestError( + create_mock_http_error(mocker, data=None, status=400), + "Failure in HTTP call 400 Client Error: Bad Request for url: https://ecm-east.us.code42.com/svc/api/v2/user/getbyusername.", + ) + + @pytest.fixture def change_org_response(mocker): return create_mock_response(mocker) @@ -173,6 +210,17 @@ def get_user_id_success(cli_state, get_users_response): cli_state.sdk.users.get_by_username.return_value = get_users_response +@pytest.fixture +def get_user_uid_success(cli_state, get_user_response): + """detectionlists.get_user returns a single user""" + cli_state.sdk.detectionlists.get_user.return_value = get_user_response + + +@pytest.fixture +def get_user_uid_failure(cli_state, get_user_failure): + cli_state.sdk.detectionlists.get_user.side_effect = get_user_failure + + @pytest.fixture def get_user_id_failure(mocker, cli_state): cli_state.sdk.users.get_by_username.return_value = create_mock_response( @@ -248,6 +296,27 @@ def change_org_success(cli_state, change_org_response): cli_state.sdk.users.change_org_assignment.return_value = change_org_response +@pytest.fixture +def add_alias_success(mocker, cli_state): + cli_state.sdk.detectionlists.add_user_cloud_alias.return_value = create_mock_response( + mocker + ) + + +@pytest.fixture +def add_alias_limit_failure(mocker, cli_state): + cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = Py42CloudAliasLimitExceededError( + create_mock_http_error(mocker) + ) + + +@pytest.fixture +def remove_alias_success(mocker, cli_state): + cli_state.sdk.detectionlists.remove_user_cloud_alias.return_value = create_mock_response( + mocker + ) + + @pytest.fixture def worker_stats_factory(mocker): return mocker.patch(f"{_NAMESPACE}.create_worker_stats") @@ -1356,3 +1425,236 @@ def test_orgs_show_when_invalid_org_uid_raises_error(runner, cli_state, custom_e result = runner.invoke(cli, ["users", "orgs", "show", TEST_ORG_UID], obj=cli_state) assert result.exit_code == 1 assert f"Invalid org UID {TEST_ORG_UID}." in result.output + + +def test_list_aliases_calls_get_user_with_expected_parameters(runner, cli_state): + username = "alias@example" + command = ["users", "list-aliases", username] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.detectionlists.get_user.assert_called_once_with("alias@example") + + +def test_list_aliases_prints_no_aliases_found_when_empty_list( + runner, cli_state, mocker +): + cli_state.sdk.detectionlists.get_user.return_value = create_mock_response( + mocker, data={"cloudUsernames": []} + ) + username = "alias@example" + command = ["users", "list-aliases", username] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 0 + assert f"No cloud aliases for user '{username}' found" in result.output + + +def test_list_aliases_prints_expected_data(runner, cli_state, get_user_uid_success): + result = runner.invoke( + cli, ["users", "list-aliases", "alias@example.com"], obj=cli_state + ) + assert result.exit_code == 0 + assert "['Sample.User1@samplecase.com', 'Sample.User1@gmail.com']" in result.output + + +def test_list_aliases_raises_error_when_user_does_not_exist( + runner, cli_state, get_user_uid_failure +): + username = "fake@notreal.com" + command = ["users", "list-aliases", username] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert ( + f"User '{username}' does not exist or you do not have permission to view them." + in result.output + ) + + +def test_add_cloud_alias_calls_add_cloud_alias_with_correct_parameters( + runner, cli_state, get_user_uid_success, add_alias_success +): + command = ["users", "add-alias", "test@example.com", "alias@example.com"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( + TEST_USER_UID, "alias@example.com" + ) + + +def test_add_alias_raises_error_when_user_does_not_exist( + runner, cli_state, get_user_uid_failure +): + username = "fake@notreal.com" + command = ["users", "add-alias", username, "alias@example.com"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert ( + f"User '{username}' does not exist or you do not have permission to view them." + in result.output + ) + + +def test_add_alias_raises_error_when_alias_is_too_long(runner, cli_state): + command = [ + "users", + "add-alias", + "fake@notreal.com", + "alias-is-too-long-its-very-long-for-real-more-than-50-characters@example.com", + ] + cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = ( + Py42CloudAliasCharacterLimitExceededError + ) + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert "Cloud alias character limit exceeded" in result.output + + +def test_add_alias_raises_error_when_user_has_two_aliases( + runner, cli_state, add_alias_limit_failure +): + command = [ + "users", + "add-alias", + "fake@notreal.com", + "second_alias", + ] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert ( + "Error: Cloud alias limit exceeded. A max of 2 cloud aliases are allowed." + in result.output + ) + + +def test_remove_cloud_alias_calls_remove_cloud_alias_with_correct_parameters( + runner, cli_state, get_user_uid_success, remove_alias_success +): + command = ["users", "remove-alias", "test@example.com", "alias@example.com"] + runner.invoke(cli, command, obj=cli_state) + cli_state.sdk.detectionlists.remove_user_cloud_alias.assert_called_once_with( + TEST_USER_UID, "alias@example.com" + ) + + +def test_remove_alias_raises_error_when_user_does_not_exist( + runner, cli_state, get_user_uid_failure +): + username = "fake@notreal.com" + command = ["users", "remove-alias", username, "alias@example.com"] + result = runner.invoke(cli, command, obj=cli_state) + assert result.exit_code == 1 + assert ( + f"User '{username}' does not exist or you do not have permission to view them." + in result.output + ) + + +def test_bulk_add_alias_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_add_alias.csv", "w") as csv: + csv.writelines(["username,alias\n", f"{TEST_USERNAME},{TEST_ALIAS}\n"]) + runner.invoke( + cli, ["users", "bulk", "add-alias", "test_add_alias.csv"], obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "alias": TEST_ALIAS, "alias added": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_add_alias_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_add_alias.csv", "w") as csv: + csv.writelines( + ["username,alias\n\n\n", f"{TEST_USERNAME},{TEST_ALIAS}\n\n\n"] + ) + runner.invoke( + cli, ["users", "bulk", "add-alias", "test_add_alias.csv"], obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "alias": TEST_ALIAS, "alias added": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_add_alias_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats, get_user_response +): + lines = ["username,alias\n", f"{TEST_USERNAME},{TEST_ALIAS}\n"] + + def _get(username, *args, **kwargs): + if username == "test@example.com": + raise Exception("TEST") + return get_user_response + + cli_state.sdk.detectionlists.get_user.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_add_alias.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, ["users", "bulk", "add-alias", "test_add_alias.csv"], obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler(username="test@example.com", alias=TEST_ALIAS) + handler(username="not.test@example.com", alias=TEST_ALIAS) + assert worker_stats.increment_total_errors.call_count == 1 + + +def test_bulk_remove_alias_uses_expected_arguments(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove_alias.csv", "w") as csv: + csv.writelines(["username,alias\n", f"{TEST_USERNAME},{TEST_ALIAS}\n"]) + runner.invoke( + cli, + ["users", "bulk", "remove-alias", "test_remove_alias.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "alias": TEST_ALIAS, "alias removed": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_remove_alias_ignores_blank_lines(runner, mocker, cli_state): + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove_alias.csv", "w") as csv: + csv.writelines( + ["username,alias\n\n\n", f"{TEST_USERNAME},{TEST_ALIAS}\n\n\n"] + ) + runner.invoke( + cli, + ["users", "bulk", "remove-alias", "test_remove_alias.csv"], + obj=cli_state, + ) + assert bulk_processor.call_args[0][1] == [ + {"username": TEST_USERNAME, "alias": TEST_ALIAS, "alias removed": "False"} + ] + bulk_processor.assert_called_once() + + +def test_bulk_remove_alias_uses_handler_that_when_encounters_error_increments_total_errors( + runner, mocker, cli_state, worker_stats, get_user_response +): + lines = ["username,alias\n", f"{TEST_USERNAME},{TEST_ALIAS}\n"] + + def _get(username, *args, **kwargs): + if username == "test@example.com": + raise Exception("TEST") + return get_user_response + + cli_state.sdk.detectionlists.get_user.side_effect = _get + bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") + with runner.isolated_filesystem(): + with open("test_remove_alias.csv", "w") as csv: + csv.writelines(lines) + runner.invoke( + cli, + ["users", "bulk", "remove-alias", "test_remove_alias.csv"], + obj=cli_state, + ) + handler = bulk_processor.call_args[0][0] + handler(username="test@example.com", alias=TEST_ALIAS) + handler(username="not.test@example.com", alias=TEST_ALIAS) + assert worker_stats.increment_total_errors.call_count == 1 From 53486680e8eda326beef663f0390325d83338d56 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 1 Mar 2022 14:33:00 -0600 Subject: [PATCH 302/349] replace recommonmark with myst_parser (#358) --- CONTRIBUTING.md | 2 +- docs/commands.md | 20 ++++++++++++++++ docs/conf.py | 28 +++++++++++------------ docs/guides.md | 34 ++++++++++++++++++++++------ docs/index.md | 16 +++++++++++++ docs/userguides/gettingstarted.md | 2 +- docs/userguides/siemexample.md | 4 ++-- docs/userguides/trustedactivities.md | 2 +- tox.ini | 6 ++--- 9 files changed, 85 insertions(+), 29 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 295b08529..e810d6ee9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,7 +172,7 @@ tox -e docs To build and run the documentation locally, run the following from the `docs` directory: ```bash -pip install sphinx recommonmark sphinx_rtd_theme +pip install sphinx myst_parser sphinx_rtd_theme make html ``` diff --git a/docs/commands.md b/docs/commands.md index c697e87d9..25050308c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,5 +1,25 @@ # Commands +```{eval-rst} +.. toctree:: + :hidden: + :maxdepth: 2 + :glob: + + Alert Rules + Alerts + Audit Logs + Cases + Departing Employee + Devices + High Risk Employee + Legal Hold + Profile + Security Data + Trusted Activities + Users +``` + * [Alert Rules](commands/alertrules.rst) * [Alerts](commands/alerts.rst) * [Audit Logs](commands/auditlogs.rst) diff --git a/docs/conf.py b/docs/conf.py index c5f9eb311..31a324dc1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,8 +12,6 @@ import os import sys -from recommonmark.transform import AutoStructify - import code42cli.__version__ as meta # -- Project information ----------------------------------------------------- @@ -40,10 +38,13 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.napoleon", - "recommonmark", + "myst_parser", "sphinx_click", ] +# Add myst_parser types to suppress warnings +suppress_warnings = ["myst.header"] + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -85,8 +86,15 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -# -html_theme_options = {"style_nav_header_background": "#f0f0f0", "logo_only": True} + +html_theme_options = { + "style_nav_header_background": "#f0f0f0", + "logo_only": True, + # TOC options + "navigation_depth": 4, + "titles_only": True, + "collapse_navigation": False, +} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -111,15 +119,7 @@ def setup(app): - app.add_config_value( - "recommonmark_config", - { - # 'url_resolver': lambda url: github_doc_root + url, 'auto_toc_tree_maxdepth': 2, - "enable_eval_rst": True - }, - True, - ) - app.add_transform(AutoStructify) + pass sys.path.insert(0, os.path.abspath("..")) diff --git a/docs/guides.md b/docs/guides.md index 5fb3739b7..9b5a9310c 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -1,14 +1,34 @@ # User Guides +```{eval-rst} +.. toctree:: + :hidden: + :maxdepth: 2 + :glob: + + Get started with the Code42 command-line interface (CLI) + Configure a profile + Ingest data into a SIEM + Manage detection list users + Manage legal hold users + Clean up your environment by deactivating devices + Write custom extension scripts using the Code42 CLI and Py42 + Manage users + Configure trusted activities + Configure alert rules + Add and manage cases + Perform bulk actions +``` + * [Get started with the Code42 command-line interface (CLI)](userguides/gettingstarted.md) * [Configure a profile](userguides/profile.md) -* [Ingest Data into a SIEM](userguides/siemexample.md) +* [Ingest data into a SIEM](userguides/siemexample.md) * [Manage detection list users](userguides/detectionlists.md) * [Manage legal hold users](userguides/legalhold.md) * [Clean up your environment by deactivating devices](userguides/deactivatedevices.md) -* [Write custom extension scripts using the Code42 CLI and py42](userguides/extensions.md) -* [Manage Users](userguides/users.md) -* [Configure Trusted Activities](userguides/trustedactivities.md) -* [Configure Alert Rules](userguides/alertrules.md) -* [Add and Manage Cases](userguides/cases.md) -* [Use Bulk Commands](userguides/bulkcommands.md) +* [Write custom extension scripts using the Code42 CLI and Py42](userguides/extensions.md) +* [Manage users](userguides/users.md) +* [Configure trusted activities](userguides/trustedactivities.md) +* [Configure alert rules](userguides/alertrules.md) +* [Add and manage cases](userguides/cases.md) +* [Perform bulk actions](userguides/bulkcommands.md) diff --git a/docs/index.md b/docs/index.md index aab85d45d..51465879f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,21 @@ # Code42 command-line interface (CLI) +```{eval-rst} +.. toctree:: + :hidden: + :maxdepth: 2 + + guides +``` + +```{eval-rst} +.. toctree:: + :hidden: + :maxdepth: 2 + + commands +``` + [![license](https://img.shields.io/pypi/l/code42cli.svg)](https://pypi.org/project/code42cli/) [![versions](https://img.shields.io/pypi/pyversions/code42cli.svg)](https://pypi.org/project/code42cli/) diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index 77a2363fc..c58e5e0a9 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -68,7 +68,7 @@ python3 -m pip install code42cli --upgrade ## Authentication -```eval_rst +```{eval-rst} .. important:: The Code42 CLI currently only supports token-based authentication. ``` diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md index 5384525ad..4cfdbfa87 100644 --- a/docs/userguides/siemexample.md +++ b/docs/userguides/siemexample.md @@ -174,7 +174,7 @@ The following tables map the file event data from the Code42 CLI to common event The table below maps JSON fields, CEF fields, and [Forensic Search fields](https://code42.com/r/support/forensic-search-fields) to one another. -```eval_rst +```{eval-rst} +----------------------------+---------------------------------+----------------------------------------+ | JSON field | CEF field | Forensic Search field | @@ -255,7 +255,7 @@ to one another. See the table below to map file events to CEF signature IDs. -```eval_rst +```{eval-rst} +--------------------+-----------+ | Exfiltration event | CEF field | diff --git a/docs/userguides/trustedactivities.md b/docs/userguides/trustedactivities.md index d41e6a5f4..a40daa6fe 100644 --- a/docs/userguides/trustedactivities.md +++ b/docs/userguides/trustedactivities.md @@ -49,7 +49,7 @@ To update multiple trusted activities at once, enter information about the trust code42 trusted-activities bulk update update_trusted_activities.csv ``` -```eval_rst +```{eval-rst} .. note:: The ``bulk update`` command cannot be used to clear the description of a trusted activity because you cannot indicate an empty string in a CSV format. Pass an empty string to the ``description`` option of the ``update`` command to clear the description of a trusted activity. diff --git a/tox.ini b/tox.ini index ceb8cfad2..3557a4d73 100644 --- a/tox.ini +++ b/tox.ini @@ -24,9 +24,9 @@ commands = [testenv:docs] deps = - sphinx - recommonmark - sphinx_rtd_theme + sphinx == 4.4.0 + myst_parser == 0.16 + sphinx_rtd_theme == 0.5.2 sphinx-click whitelist_externals = bash From e19405f1f9db7697079524ead8f191e76bcf4928 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Mon, 7 Mar 2022 08:16:48 -0600 Subject: [PATCH 303/349] better help messages (#357) * better help messages * adjust docs --- src/code42cli/cmds/users.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 8341afe12..cf98b6754 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -234,7 +234,9 @@ def change_organization(state, username, org_id): @click.argument("alias") @sdk_options() def add_alias(state, username, alias): - """Add a cloud alias for a given user.""" + """Add a cloud alias for a given user. + + A cloud alias is the username an employee uses to access cloud services such as Google Drive or Box. Adding a cloud alias allows Incydr to link a user's cloud activity with their Code42 username. Each user has a default cloud alias of their Code42 username. You can add one additional alias.""" _add_cloud_alias(state.sdk, username, alias) @@ -251,7 +253,7 @@ def remove_alias(state, username, alias): @click.argument("username") @sdk_options() def list_aliases(state, username): - """List the cloud aliases for a given user.""" + """List the cloud aliases for a given user. Each user has a default cloud alias of their Code42 username with up to one additional alias.""" user = _get_user(state.sdk, username) aliases = user["cloudUsernames"] if aliases: @@ -577,7 +579,7 @@ def handle_row(**row): @bulk.command( name="add-alias", - help=f"Add aliases to a list of users from the provided CSV in format: {','.join(_bulk_user_alias_headers)}", + help=f"Add aliases to a list of users from the provided CSV in format: {','.join(_bulk_user_alias_headers)}.\n\nA cloud alias is the username an employee uses to access cloud services such as Google Drive or Box. Adding a cloud alias allows Incydr to link a user's cloud activity with their Code42 username. Each user has a default cloud alias of their Code42 username. You can add one additional alias.", ) @read_csv_arg(headers=_bulk_user_alias_headers) @format_option From 52a47258f958e95efc509b496e2c5eb97f4ff254 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Fri, 1 Apr 2022 12:44:12 -0500 Subject: [PATCH 304/349] Chore/rename exception catch (#360) * rename exception catching * upgrade black to 22.3.0 * upgrade cla assistant * improved error checking * bump py42 version --- .github/workflows/cla.yml | 4 +- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 10 ++- CONTRIBUTING.md | 4 +- setup.py | 2 +- src/code42cli/cmds/alerts.py | 4 +- src/code42cli/cmds/cases.py | 12 +++- src/code42cli/cmds/departing_employee.py | 4 +- src/code42cli/cmds/devices.py | 20 +++++- src/code42cli/cmds/high_risk_employee.py | 8 ++- src/code42cli/cmds/profile.py | 5 +- src/code42cli/cmds/securitydata.py | 3 +- src/code42cli/cmds/trustedactivities.py | 16 +++-- src/code42cli/cmds/users.py | 12 +++- src/code42cli/cmds/util.py | 16 ++--- src/code42cli/logger/handlers.py | 3 +- src/code42cli/worker.py | 3 +- tests/cmds/test_alert_rules.py | 34 ++++++---- tests/cmds/test_alerts.py | 40 ++++++++--- tests/cmds/test_auditlogs.py | 32 ++++++--- tests/cmds/test_cases.py | 64 ++++++++++++++---- tests/cmds/test_departing_employee.py | 22 +++++-- tests/cmds/test_devices.py | 28 ++++++-- tests/cmds/test_high_risk_employee.py | 4 +- tests/cmds/test_legal_hold.py | 20 ++++-- tests/cmds/test_profile.py | 9 ++- tests/cmds/test_securitydata.py | 84 ++++++++++++++++++------ tests/cmds/test_trustedactivities.py | 32 ++++++--- tests/cmds/test_users.py | 65 ++++++++++++------ tests/integration/test_auditlogs.py | 3 +- tests/logger/test_init.py | 10 ++- tests/test_bulk.py | 4 +- tests/test_file_readers.py | 6 +- tests/test_magic_date_type.py | 7 +- 34 files changed, 431 insertions(+), 161 deletions(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 576255111..4546a315c 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -12,7 +12,7 @@ jobs: - name: "CLA Assistant" if: (github.event.comment.body == 'recheckcla' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' # Alpha Release - uses: cla-assistant/github-action@v2.0.1-alpha + uses: cla-assistant/github-action@v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret @@ -22,7 +22,7 @@ jobs: path-to-cla-document: 'https://code42.github.io/code42-cla/Code42_Individual_Contributor_License_Agreement' # branch should not be protected branch: 'main' - allowlist: alang13,unparalleled-js,kiran-chaudhary,timabrmsn,ceciliastevens,DiscoRiver,annie-payseur,amoravec,patelsagar192 + allowlist: alang13,unparalleled-js,kiran-chaudhary,timabrmsn,ceciliastevens,DiscoRiver,annie-payseur,amoravec,patelsagar192,tora-kozic #below are the optional inputs - If the optional inputs are not given, then default values will be taken #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 132ee5794..64d8f2b55 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: - id: reorder-python-imports args: ["--application-directories", "src"] - repo: https://github.com/psf/black - rev: 19.10b0 + rev: 22.3.0 hooks: - id: black - repo: https://gitlab.com/pycqa/flake8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 456ed0a9b..920348e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,15 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ## Unreleased ### Added -- `users add-alias`, `users remove-alias`, `users bulk add-alias`, and `users bulk remove-alias` for managing cloud aliases for users. +- `departing-employee bulk remove` and `high-risk-employee bulk remove` commands now accept an optional header, as well as extraneous columns if a header is provided. +- Added `devices rename` and `devices bulk rename` commands to rename devices. + - *Note: Incydr devices cannot be renamed.* +- Added the following commands for managing users' cloud aliases: + - `users add-alias` + - `users remove-alias` + - `users list-aliases` + - `users bulk add-alias` + - `users bulk remove-alias` ## 1.12.1 - 2022-01-21 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e810d6ee9..dd2e2c3a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,8 @@ pyenv virtualenv 3.6.10 code42cli pyenv activate code42cli ``` +**Note**: The CLI supports pythons versions 3.6 through 3.9 - any versions within that range should work for your virtual environment. Use `pyenv --versions` to see all versions available for install. There are some known issues installing python 3.6 with pyenv on certain OS. + Use `source deactivate` to exit the virtual environment and `pyenv activate code42cli` to reactivate it. ### Windows/Linux @@ -76,7 +78,7 @@ Next, with your virtual environment activated, install code42cli and its develop ["editable mode"](https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs). ```bash -pip install -e .[dev] +pip install -e .'[dev]' ``` Open the project in your IDE of choice and change the python environment to diff --git a/setup.py b/setup.py index 092cf7b21..7b8a61be2 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython==7.16.3", "pandas>=1.1.3", - "py42>=1.21.1", + "py42>=1.22.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 1a55bc26d..6d90ac031 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -432,7 +432,9 @@ def handle_row(id, state, note): _update_alert(sdk, id, state, note) run_bulk_process( - handle_row, csv_rows, progress_label="Updating alerts:", + handle_row, + csv_rows, + progress_label="Updating alerts:", ) diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index b37df6039..99e518af1 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -24,7 +24,10 @@ case_number_option = click.option( "--case-number", type=int, help="The number assigned to the case.", required=True ) -name_option = click.option("--name", help="The name of the case.",) +name_option = click.option( + "--name", + help="The name of the case.", +) assignee_option = click.option( "--assignee", help="The UID of the user to assign to the case." ) @@ -117,7 +120,8 @@ def update(state, case_number, name, subject, assignee, description, findings, s @cases.command("list") @click.option( - "--name", help="Filter by name of a case. Supports partial name matches.", + "--name", + help="Filter by name of a case. Supports partial name matches.", ) @click.option("--subject", help="Filter by the user UID of the subject of a case.") @click.option("--assignee", help="Filter by the user UID of an assignee.") @@ -299,7 +303,9 @@ def handle_row(number, event_id): sdk.cases.file_events.add(number, event_id) run_bulk_process( - handle_row, csv_rows, progress_label="Associating file events to cases:", + handle_row, + csv_rows, + progress_label="Associating file events to cases:", ) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index ace6b9854..9a729104d 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -49,7 +49,9 @@ def _list(state, format, filter): """Lists the users on the Departing Employees list.""" employee_generator = _get_departing_employees(state.sdk, filter) list_employees( - employee_generator, format, {"departureDate": "Departure Date"}, + employee_generator, + format, + {"departureDate": "Departure Date"}, ) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index ee81ca9cd..36f53e36f 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -8,6 +8,7 @@ from pandas import Series from pandas import to_datetime from py42 import exceptions +from py42.clients.settings.device_settings import IncydrDeviceSettings from py42.exceptions import Py42NotFoundError from code42cli.bulk import generate_template_cmd_factory @@ -35,7 +36,9 @@ def devices(state): device_guid_argument = click.argument( - "device-guid", type=str, callback=lambda ctx, param, arg: _verify_guid_type(arg), + "device-guid", + type=str, + callback=lambda ctx, param, arg: _verify_guid_type(arg), ) new_device_name_option = click.option( @@ -159,8 +162,16 @@ def _update_cold_storage_purge_date(sdk, guid, purge_date): def _change_device_name(sdk, guid, name): try: device_settings = sdk.devices.get_settings(guid) + if isinstance(device_settings, IncydrDeviceSettings): + raise Code42CLIError( + "Failed to rename device. Incydr devices cannot be renamed." + ) device_settings.name = name sdk.devices.update_settings(device_settings) + except KeyError: + raise Code42CLIError( + "Failed to rename device. This device is missing expected settings fields." + ) except exceptions.Py42ForbiddenError: raise Code42CLIError( f"You don't have the necessary permissions to rename the device with GUID '{guid}'." @@ -500,7 +511,12 @@ def _break_backup_usage_into_total_storage(backup_usage): @format_option @sdk_options() def list_backup_sets( - state, active, inactive, org_uid, include_usernames, format, + state, + active, + inactive, + org_uid, + include_usernames, + format, ): """Get information about many devices and their backup sets.""" if inactive: diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index d31f5e7b4..152effc7c 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -176,7 +176,9 @@ def handle_row(username, tag): _add_risk_tags(sdk, username, tag) run_bulk_process( - handle_row, csv_rows, progress_label="Adding risk tags to users:", + handle_row, + csv_rows, + progress_label="Adding risk tags to users:", ) @@ -194,7 +196,9 @@ def handle_row(username, tag): _remove_risk_tags(sdk, username, tag) run_bulk_process( - handle_row, csv_rows, progress_label="Removing risk tags from users:", + handle_row, + csv_rows, + progress_label="Removing risk tags from users:", ) diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 756a30bfb..0a3e56165 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -21,7 +21,10 @@ def profile(): debug_option = click.option( - "-d", "--debug", is_flag=True, help="Turn on debug logging.", + "-d", + "--debug", + is_flag=True, + help="Turn on debug logging.", ) totp_option = click.option( "--totp", help="TOTP token for multi-factor authentication.", type=TOTP() diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 0fc27cb4d..475e53266 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -233,7 +233,8 @@ def callback(ctx, param, arg): "--risk-indicator", multiple=True, type=MapChoice( - choices=list(risk_indicator_map.keys()), extras_map=risk_indicator_map_reversed, + choices=list(risk_indicator_map.keys()), + extras_map=risk_indicator_map_reversed, ), callback=risk_indicator_callback(f.RiskIndicator), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, diff --git a/src/code42cli/cmds/trustedactivities.py b/src/code42cli/cmds/trustedactivities.py index 1397be5b2..342772d20 100644 --- a/src/code42cli/cmds/trustedactivities.py +++ b/src/code42cli/cmds/trustedactivities.py @@ -55,7 +55,9 @@ def create(state, type, value, description): VALUE is the name of the domain or Slack workspace. """ state.sdk.trustedactivities.create( - type, value, description=description, + type, + value, + description=description, ) @@ -67,7 +69,9 @@ def create(state, type, value, description): def update(state, resource_id, value, description): """Update a trusted activity. Requires the activity's resource ID.""" state.sdk.trustedactivities.update( - resource_id, value=value, description=description, + resource_id, + value=value, + description=description, ) @@ -154,7 +158,9 @@ def handle_row(type, value, description): sdk.trustedactivities.create(type, value, description) run_bulk_process( - handle_row, csv_rows, progress_label="Creating trusting activities:", + handle_row, + csv_rows, + progress_label="Creating trusting activities:", ) @@ -200,7 +206,9 @@ def handle_row(resource_id): sdk.trustedactivities.delete(resource_id) run_bulk_process( - handle_row, csv_rows, progress_label="Removing trusted activities:", + handle_row, + csv_rows, + progress_label="Removing trusted activities:", ) diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index cf98b6754..f67e43839 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -28,7 +28,10 @@ help="Limit users to only those in the organization you specify. Note that child orgs are included.", ) active_option = click.option( - "--active", is_flag=True, help="Limits results to only active users.", default=None, + "--active", + is_flag=True, + help="Limits results to only active users.", + default=None, ) inactive_option = click.option( "--inactive", @@ -288,7 +291,8 @@ def _get_orgs_header(): @format_option @sdk_options() def list_orgs( - state, format, + state, + format, ): """List all orgs.""" pages = state.sdk.orgs.get_all() @@ -305,7 +309,9 @@ def list_orgs( @format_option @sdk_options() def show_org( - state, org_uid, format, + state, + org_uid, + format, ): """Show org details.""" formatter = OutputFormatter(format) diff --git a/src/code42cli/cmds/util.py b/src/code42cli/cmds/util.py index ce6ad557d..911bab8ca 100644 --- a/src/code42cli/cmds/util.py +++ b/src/code42cli/cmds/util.py @@ -66,14 +66,14 @@ def try_get_default_header(include_all, default_header, output_format): def create_time_range_filter(filter_cls, begin_date=None, end_date=None): """Creates a filter using the given filter class (must be a subclass of - :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) and date args. Returns - `None` if both begin_date and end_date args are `None`. - - Args: - filter_cls: The class of filter to create. (must be a subclass of - :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) - begin_date: The begin date for the range. - end_date: The end date for the range. + :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) and date args. Returns + `None` if both begin_date and end_date args are `None`. + + Args: + filter_cls: The class of filter to create. (must be a subclass of + :class:`py42.sdk.queries.query_filter.QueryFilterTimestampField`) + begin_date: The begin date for the range. + end_date: The end date for the range. """ if not issubclass(filter_cls, QueryFilterTimestampField): raise Exception("filter_cls must be a subclass of QueryFilterTimestampField") diff --git a/src/code42cli/logger/handlers.py b/src/code42cli/logger/handlers.py index 5f37458ed..6412e3a0a 100644 --- a/src/code42cli/logger/handlers.py +++ b/src/code42cli/logger/handlers.py @@ -50,8 +50,7 @@ def _wrap_socket(self): return self._protocol == ServerProtocol.TLS_TCP def connect_socket(self): - """Call to initialize the socket. If using TCP/TLS, it will also establish the connection. - """ + """Call to initialize the socket. If using TCP/TLS, it will also establish the connection.""" if not self.socket: self.socket = self._create_socket(self._hostname, self._port, self._certs) diff --git a/src/code42cli/worker.py b/src/code42cli/worker.py index 8c6004c00..3a0983056 100644 --- a/src/code42cli/worker.py +++ b/src/code42cli/worker.py @@ -98,8 +98,7 @@ def do_async(self, func, *args, **kwargs): @property def stats(self): - """Stats about the tasks that have been executed, such as the total errors that occurred. - """ + """Stats about the tasks that have been executed, such as the total errors that occurred.""" return self._stats def wait(self): diff --git a/tests/cmds/test_alert_rules.py b/tests/cmds/test_alert_rules.py index 2fd678913..fa41357fc 100644 --- a/tests/cmds/test_alert_rules.py +++ b/tests/cmds/test_alert_rules.py @@ -118,8 +118,8 @@ def test_add_user_adds_user_list_to_alert_rules(runner, cli_state): def test_add_user_when_returns_invalid_rule_type_error_and_system_rule_exits( mocker, runner, cli_state ): - cli_state.sdk.alerts.rules.add_user.side_effect = create_invalid_rule_type_side_effect( - mocker + cli_state.sdk.alerts.rules.add_user.side_effect = ( + create_invalid_rule_type_side_effect(mocker) ) result = runner.invoke( cli, @@ -149,8 +149,8 @@ def test_add_user_when_returns_500_and_not_system_rule_raises_Py42InternalServer def test_add_user_when_rule_not_found_prints_expected_output(mocker, runner, cli_state): - cli_state.sdk.alerts.rules.get_by_observer_id.side_effect = get_rule_not_found_side_effect( - mocker + cli_state.sdk.alerts.rules.get_by_observer_id.side_effect = ( + get_rule_not_found_side_effect(mocker) ) result = runner.invoke( cli, @@ -177,8 +177,8 @@ def test_remove_user_removes_user_list_from_alert_rules(runner, cli_state): def test_remove_user_when_raise_invalid_rule_type_error_and_system_rule_raises_InvalidRuleTypeError( mocker, runner, cli_state ): - cli_state.sdk.alerts.rules.remove_user.side_effect = create_invalid_rule_type_side_effect( - mocker + cli_state.sdk.alerts.rules.remove_user.side_effect = ( + create_invalid_rule_type_side_effect(mocker) ) result = runner.invoke( cli, @@ -196,8 +196,8 @@ def test_remove_user_when_raise_invalid_rule_type_error_and_system_rule_raises_I def test_remove_user_when_rule_not_found_prints_expected_output( mocker, runner, cli_state ): - cli_state.sdk.alerts.rules.get_by_observer_id.side_effect = get_rule_not_found_side_effect( - mocker + cli_state.sdk.alerts.rules.get_by_observer_id.side_effect = ( + get_rule_not_found_side_effect(mocker) ) result = runner.invoke( cli, @@ -210,8 +210,8 @@ def test_remove_user_when_rule_not_found_prints_expected_output( def test_remove_user_when_raises_invalid_rule_type_side_effect_and_not_system_rule_raises_Py42InternalServerError( mocker, runner, cli_state ): - cli_state.sdk.alerts.rules.remove_user.side_effect = create_invalid_rule_type_side_effect( - mocker + cli_state.sdk.alerts.rules.remove_user.side_effect = ( + create_invalid_rule_type_side_effect(mocker) ) result = runner.invoke( cli, @@ -317,8 +317,8 @@ def test_list_cmd_formats_to_csv_when_format_is_passed(runner, cli_state): def test_remove_when_user_not_on_rule_raises_expected_error(runner, cli_state, mocker): - cli_state.sdk.alerts.rules.remove_user.side_effect = get_user_not_on_alert_rule_side_effect( - mocker + cli_state.sdk.alerts.rules.remove_user.side_effect = ( + get_user_not_on_alert_rule_side_effect(mocker) ) test_username = "test@example.com" test_rule_id = "101010" @@ -347,8 +347,14 @@ def test_remove_when_user_not_on_rule_raises_expected_error(runner, cli_state, m (f"{ALERT_RULES_COMMAND} add-user", "Missing option '--rule-id'."), (f"{ALERT_RULES_COMMAND} remove-user", "Missing option '--rule-id'."), (f"{ALERT_RULES_COMMAND} show", "Missing argument 'RULE_ID'."), - (f"{ALERT_RULES_COMMAND} bulk add", "Error: Missing argument 'CSV_FILE'.",), - (f"{ALERT_RULES_COMMAND} bulk remove", "Error: Missing argument 'CSV_FILE'.",), + ( + f"{ALERT_RULES_COMMAND} bulk add", + "Error: Missing argument 'CSV_FILE'.", + ), + ( + f"{ALERT_RULES_COMMAND} bulk remove", + "Error: Missing argument 'CSV_FILE'.", + ), ], ) def test_alert_rules_command_when_missing_required_parameters_errors( diff --git a/tests/cmds/test_alerts.py b/tests/cmds/test_alerts.py index 0a3ebdb4c..1cce32ebd 100644 --- a/tests/cmds/test_alerts.py +++ b/tests/cmds/test_alerts.py @@ -471,7 +471,9 @@ def test_search_and_send_to_when_advanced_query_passed_as_json_string_builds_exp cli_state, runner, command, search_all_alerts_success ): runner.invoke( - cli, [*command, "--advanced-query", ADVANCED_QUERY_JSON], obj=cli_state, + cli, + [*command, "--advanced-query", ADVANCED_QUERY_JSON], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] passed_filter_groups = query._filter_group_list @@ -532,7 +534,9 @@ def test_search_and_send_to_when_given_begin_and_end_dates_uses_expected_query( end_date = get_test_date_str(days_ago=1) runner.invoke( - cli, [*command, "--begin", begin_date, "--end", end_date], obj=cli_state, + cli, + [*command, "--begin", begin_date, "--end", end_date], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] query_dict = dict(query) @@ -636,7 +640,9 @@ def test_search_and_send_to_when_end_date_is_before_begin_date_causes_exit( begin_date = get_test_date_str(days_ago=1) end_date = get_test_date_str(days_ago=3) result = runner.invoke( - cli, [*command, "--begin", begin_date, "--end", end_date], obj=cli_state, + cli, + [*command, "--begin", begin_date, "--end", end_date], + obj=cli_state, ) assert result.exit_code == 2 assert "'--begin': cannot be after --end date" in result.output @@ -687,7 +693,9 @@ def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_check search_all_alerts_success, ): res = runner.invoke( - cli, [*command, "--use-checkpoint", "test", "--begin", "1d"], obj=cli_state, + cli, + [*command, "--use-checkpoint", "test", "--begin", "1d"], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] query_dict = dict(query) @@ -703,7 +711,9 @@ def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_c cli_state, alert_cursor_with_checkpoint, runner, command, search_all_alerts_success ): result = runner.invoke( - cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, + cli, + [*command, "--use-checkpoint", "test", "--begin", "1h"], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] assert result.exit_code == 0 @@ -732,7 +742,9 @@ def test_search_and_send_to_when_given_exclude_actor_uses_actor_filter( ): actor_name = "test.testerson" runner.invoke( - cli, [*command, "--begin", "1h", "--exclude-actor", actor_name], obj=cli_state, + cli, + [*command, "--begin", "1h", "--exclude-actor", actor_name], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] assert f.Actor.not_in([actor_name]) in query._filter_group_list @@ -744,7 +756,9 @@ def test_search_and_send_to_when_given_rule_name_uses_rule_name_filter( ): rule_name = "departing employee" runner.invoke( - cli, [*command, "--begin", "1h", "--rule-name", rule_name], obj=cli_state, + cli, + [*command, "--begin", "1h", "--rule-name", rule_name], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] assert f.RuleName.is_in([rule_name]) in query._filter_group_list @@ -770,7 +784,9 @@ def test_search_and_send_to_when_given_rule_type_uses_rule_name_filter( ): rule_type = "FedEndpointExfiltration" runner.invoke( - cli, [*command, "--begin", "1h", "--rule-type", rule_type], obj=cli_state, + cli, + [*command, "--begin", "1h", "--rule-type", rule_type], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] assert f.RuleType.is_in([rule_type]) in query._filter_group_list @@ -806,7 +822,9 @@ def test_search_and_send_to_when_given_exclude_rule_id_uses_rule_name_not_filter ): rule_id = "departing employee" runner.invoke( - cli, [*command, "--begin", "1h", "--exclude-rule-id", rule_id], obj=cli_state, + cli, + [*command, "--begin", "1h", "--exclude-rule-id", rule_id], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] assert f.RuleId.not_in([rule_id]) in query._filter_group_list @@ -818,7 +836,9 @@ def test_search_and_send_to_when_given_description_uses_description_filter( ): description = "test description" runner.invoke( - cli, [*command, "--begin", "1h", "--description", description], obj=cli_state, + cli, + [*command, "--begin", "1h", "--description", description], + obj=cli_state, ) query = cli_state.sdk.alerts.get_all_alert_details.call_args[0][0] assert f.Description.contains(description) in query._filter_group_list diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index 15d131b60..8567fa1e6 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -419,7 +419,9 @@ def test_search_and_send_to_with_checkpoint_saves_expected_cursor_timestamp( ): cli_state.sdk.auditlogs.get_all.return_value = mock_audit_log_response runner.invoke( - cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + cli, + [*command, "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, ) assert audit_log_cursor_with_checkpoint.replace.call_count == 4 assert audit_log_cursor_with_checkpoint.replace.call_args_list[3][0] == ( @@ -438,7 +440,9 @@ def test_search_and_send_to_with_existing_checkpoint_replaces_begin_arg_if_passe command, ): runner.invoke( - cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + cli, + [*command, "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, ) assert ( cli_state.sdk.auditlogs.get_all.call_args[1]["begin_time"] == CURSOR_TIMESTAMP @@ -474,7 +478,9 @@ def test_search_and_send_to_without_existing_checkpoint_writes_both_event_hashes mock_audit_log_response_with_only_same_timestamps ) runner.invoke( - cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + cli, + [*command, "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, ) assert audit_log_cursor_with_checkpoint.replace_events.call_count == 2 assert audit_log_cursor_with_checkpoint.replace_events.call_args_list[1][0][1] == [ @@ -539,7 +545,9 @@ def test_search_and_send_when_timestamps_missing_milliseconds_saves_checkpoint( mock_audit_log_response_with_missing_ms_timestamp ) runner.invoke( - cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + cli, + [*command, "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, ) audit_log_cursor_with_checkpoint.replace.assert_called_once_with( "test", 1577880000.0 @@ -559,7 +567,9 @@ def test_search_and_send_when_timestamps_have_microseconds_saves_checkpoint( mock_audit_log_response_with_micro_seconds ) runner.invoke( - cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + cli, + [*command, "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, ) audit_log_cursor_with_checkpoint.replace.assert_called_once_with( "test", 1625150833.093616 @@ -579,7 +589,9 @@ def test_search_and_send_when_timestamps_have_nanoseconds_saves_checkpoint( mock_audit_log_response_with_nano_seconds ) runner.invoke( - cli, [*command, "--begin", "1d", "--use-checkpoint", "test"], obj=cli_state, + cli, + [*command, "--begin", "1d", "--use-checkpoint", "test"], + obj=cli_state, ) call_args = audit_log_cursor_with_checkpoint.replace.call_args assert call_args[0][0] == "test" @@ -596,7 +608,9 @@ def test_search_if_error_occurs_when_processing_event_timestamp_does_not_store_e mock_audit_log_response_with_error_causing_timestamp ) runner.invoke( - cli, ["audit-logs", "search", "--use-checkpoint", "test"], obj=cli_state, + cli, + ["audit-logs", "search", "--use-checkpoint", "test"], + obj=cli_state, ) # Saved the timestamp from the good event but not the bad event @@ -615,7 +629,9 @@ def test_search_when_table_format_and_using_output_via_pager_only_includes_heade mock_audit_log_response_with_10_records ) result = runner.invoke( - cli, ["audit-logs", "search", "--use-checkpoint", "test"], obj=cli_state, + cli, + ["audit-logs", "search", "--use-checkpoint", "test"], + obj=cli_state, ) output = result.output output = output.split(" ") diff --git a/tests/cmds/test_cases.py b/tests/cmds/test_cases.py index ec3557bc9..3b9a288f9 100644 --- a/tests/cmds/test_cases.py +++ b/tests/cmds/test_cases.py @@ -101,7 +101,9 @@ def update_on_a_closed_case_error(custom_error): def test_create_calls_create_with_expected_params(runner, cli_state): runner.invoke( - cli, ["cases", "create", "TEST_CASE"], obj=cli_state, + cli, + ["cases", "create", "TEST_CASE"], + obj=cli_state, ) cli_state.sdk.cases.create.assert_called_once_with( "TEST_CASE", assignee=None, description=None, findings=None, subject=None @@ -177,7 +179,9 @@ def test_update_with_optional_fields_calls_update_with_expected_params( def test_update_calls_update_with_expected_params(runner, cli_state): runner.invoke( - cli, ["cases", "update", "1", "--name", "TEST_CASE2"], obj=cli_state, + cli, + ["cases", "update", "1", "--name", "TEST_CASE2"], + obj=cli_state, ) cli_state.sdk.cases.update.assert_called_once_with( 1, @@ -199,7 +203,9 @@ def test_update_when_missing_case_number_prints_error(runner, cli_state): def test_list_calls_get_all_with_expected_params(runner, cli_state): runner.invoke( - cli, ["cases", "list"], obj=cli_state, + cli, + ["cases", "list"], + obj=cli_state, ) assert cli_state.sdk.cases.get_all.call_count == 1 @@ -211,14 +217,20 @@ def gen(): yield py42_response.data cli_state.sdk.cases.get_all.return_value = gen() - result = runner.invoke(cli, ["cases", "list"], obj=cli_state,) + result = runner.invoke( + cli, + ["cases", "list"], + obj=cli_state, + ) assert "2021-01-24T11:00:04.217878Z" in result.output assert "942897" in result.output def test_show_calls_get_case_with_expected_params(runner, cli_state): runner.invoke( - cli, ["cases", "show", "1"], obj=cli_state, + cli, + ["cases", "show", "1"], + obj=cli_state, ) cli_state.sdk.cases.get.assert_called_once_with(1) @@ -227,7 +239,9 @@ def test_show_with_include_file_events_calls_file_events_get_all_with_expected_p runner, cli_state ): runner.invoke( - cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, + cli, + ["cases", "show", "1", "--include-file-events"], + obj=cli_state, ) cli_state.sdk.cases.get.assert_called_once_with(1) cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) @@ -240,7 +254,9 @@ def test_show_when_py42_raises_exception_prints_error_message( custom_error ) result = runner.invoke( - cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, + cli, + ["cases", "show", "1", "--include-file-events"], + obj=cli_state, ) cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) assert "Invalid case-number 1." in result.output @@ -249,7 +265,11 @@ def test_show_when_py42_raises_exception_prints_error_message( def test_show_prints_expected_data(runner, cli_state, py42_response): py42_response.data = json.loads(CASE_DETAILS) cli_state.sdk.cases.get.return_value = py42_response - result = runner.invoke(cli, ["cases", "show", "1"], obj=cli_state,) + result = runner.invoke( + cli, + ["cases", "show", "1"], + obj=cli_state, + ) assert "test-single@example.com" in result.output assert "2021-01-24T11:00:04.217878Z" in result.output assert "123456" in result.output @@ -264,7 +284,9 @@ def test_show_prints_expected_data_with_include_file_events_option( cli_state.sdk.cases.get.return_value = get_case_response cli_state.sdk.cases.file_events.get_all.return_value = py42_response result = runner.invoke( - cli, ["cases", "show", "1", "--include-file-events"], obj=cli_state, + cli, + ["cases", "show", "1", "--include-file-events"], + obj=cli_state, ) assert ( "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" @@ -285,7 +307,9 @@ def test_show_case_when_missing_case_number_prints_error(runner, cli_state): def test_export_calls_export_summary_with_expected_params(runner, cli_state, mocker): with mock.patch("builtins.open", mock_open()) as mf: runner.invoke( - cli, ["cases", "export", "1"], obj=cli_state, + cli, + ["cases", "export", "1"], + obj=cli_state, ) cli_state.sdk.cases.export_summary.assert_called_once_with(1) expected = os.path.join(os.getcwd(), "1_case_summary.pdf") @@ -373,14 +397,20 @@ def test_file_events_remove_when_missing_case_number_prints_error(runner, cli_st def test_file_events_list_calls_get_all_with_expected_params(runner, cli_state): runner.invoke( - cli, ["cases", "file-events", "list", "1"], obj=cli_state, + cli, + ["cases", "file-events", "list", "1"], + obj=cli_state, ) cli_state.sdk.cases.file_events.get_all.assert_called_once_with(1) def test_file_events_list_prints_expected_data(runner, cli_state): cli_state.sdk.cases.file_events.get_all.return_value = json.loads(ALL_EVENTS) - result = runner.invoke(cli, ["cases", "file-events", "list", "1"], obj=cli_state,) + result = runner.invoke( + cli, + ["cases", "file-events", "list", "1"], + obj=cli_state, + ) assert ( "0_147e9445-2f30-4a91-8b2a-9455332e880a_973435567569502913_986467523038446097_163" in result.output @@ -399,7 +429,11 @@ def test_cases_create_when_case_name_already_exists_raises_exception_prints_erro runner, cli_state, case_already_exists_error ): cli_state.sdk.cases.create.side_effect = case_already_exists_error - result = runner.invoke(cli, ["cases", "create", "test case"], obj=cli_state,) + result = runner.invoke( + cli, + ["cases", "create", "test case"], + obj=cli_state, + ) assert ( "Case name 'test case' already exists, please set another name" in result.output ) @@ -422,7 +456,9 @@ def test_cases_update_when_description_length_limit_exceeds_raises_exception_pri ): cli_state.sdk.cases.update.side_effect = case_description_limit_exceeded_error result = runner.invoke( - cli, ["cases", "update", "1", "--description", "too long"], obj=cli_state, + cli, + ["cases", "update", "1", "--description", "too long"], + obj=cli_state, ) assert "Description limit exceeded, max 250 characters allowed." in result.output diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index 8b67f6a7f..feafddd64 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -162,7 +162,8 @@ def test_add_departing_employee_when_given_notes_updates_notes( def test_add_departing_employee_adds( - runner, cli_state_with_user, + runner, + cli_state_with_user, ): departure_date = "2020-02-02" runner.invoke( @@ -394,8 +395,8 @@ def test_add_departing_employee_when_invalid_date_format_validation_raises_error def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( mocker, runner, cli_state ): - cli_state.sdk.detectionlists.departing_employee.remove.side_effect = get_user_not_on_list_side_effect( - mocker, "departing-employee" + cli_state.sdk.detectionlists.departing_employee.remove.side_effect = ( + get_user_not_on_list_side_effect(mocker, "departing-employee") ) test_username = "test@example.com" result = runner.invoke( @@ -411,9 +412,18 @@ def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( "command, error_msg", [ (f"{DEPARTING_EMPLOYEE_COMMAND} add", "Missing argument 'USERNAME'."), - (f"{DEPARTING_EMPLOYEE_COMMAND} remove", "Missing argument 'USERNAME'.",), - (f"{DEPARTING_EMPLOYEE_COMMAND} bulk add", "Missing argument 'CSV_FILE'.",), - (f"{DEPARTING_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'CSV_FILE'.",), + ( + f"{DEPARTING_EMPLOYEE_COMMAND} remove", + "Missing argument 'USERNAME'.", + ), + ( + f"{DEPARTING_EMPLOYEE_COMMAND} bulk add", + "Missing argument 'CSV_FILE'.", + ), + ( + f"{DEPARTING_EMPLOYEE_COMMAND} bulk remove", + "Missing argument 'CSV_FILE'.", + ), ], ) def test_departing_employee_command_when_missing_required_parameters_returns_error( diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index fe9b13a55..32183000b 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -586,7 +586,9 @@ def test_deactivate_does_not_change_device_name_when_not_given_flag( ): cli_state.sdk.devices.get_settings.return_value = mock_device_settings runner.invoke( - cli, ["devices", "deactivate", TEST_DEVICE_GUID], obj=cli_state, + cli, + ["devices", "deactivate", TEST_DEVICE_GUID], + obj=cli_state, ) assert mock_device_settings.name == "testname" cli_state.sdk.devices.update_settings.assert_not_called() @@ -799,7 +801,9 @@ def test_list_invalid_org_uid_raises_error(runner, cli_state, custom_error): def test_list_excludes_recently_connected_devices_before_filtering_by_date( - runner, cli_state, get_all_devices_success, + runner, + cli_state, + get_all_devices_success, ): result = runner.invoke( cli, @@ -875,7 +879,9 @@ def test_created_after_filters_appropriate_results( cli_state, runner, get_all_devices_success ): result = runner.invoke( - cli, ["devices", "list", "--created-after", TEST_DATE_MIDDLE], obj=cli_state, + cli, + ["devices", "list", "--created-after", TEST_DATE_MIDDLE], + obj=cli_state, ) assert TEST_DATE_NEWER in result.output assert TEST_DATE_OLDER not in result.output @@ -885,7 +891,9 @@ def test_created_before_filters_appropriate_results( cli_state, runner, get_all_devices_success ): result = runner.invoke( - cli, ["devices", "list", "--created-before", TEST_DATE_MIDDLE], obj=cli_state, + cli, + ["devices", "list", "--created-before", TEST_DATE_MIDDLE], + obj=cli_state, ) assert TEST_DATE_NEWER not in result.output assert TEST_DATE_OLDER in result.output @@ -1086,7 +1094,9 @@ def test_bulk_rename_uses_expected_arguments(runner, mocker, cli_state): with open("test_bulk_rename.csv", "w") as csv: csv.writelines(["guid,name\n", "test-guid,test-name\n"]) runner.invoke( - cli, ["devices", "bulk", "rename", "test_bulk_rename.csv"], obj=cli_state, + cli, + ["devices", "bulk", "rename", "test_bulk_rename.csv"], + obj=cli_state, ) assert bulk_processor.call_args[0][1] == [ {"guid": "test-guid", "name": "test-name", "renamed": "False"} @@ -1099,7 +1109,9 @@ def test_bulk_rename_ignores_blank_lines(runner, mocker, cli_state): with open("test_bulk_rename.csv", "w") as csv: csv.writelines(["guid,name\n", "\n", "test-guid,test-name\n\n"]) runner.invoke( - cli, ["devices", "bulk", "rename", "test_bulk_rename.csv"], obj=cli_state, + cli, + ["devices", "bulk", "rename", "test_bulk_rename.csv"], + obj=cli_state, ) assert bulk_processor.call_args[0][1] == [ {"guid": "test-guid", "name": "test-name", "renamed": "False"} @@ -1121,7 +1133,9 @@ def _get(guid): with open("test_bulk_rename.csv", "w") as csv: csv.writelines(["guid,name\n", "1,2\n"]) runner.invoke( - cli, ["devices", "bulk", "rename", "test_bulk_rename.csv"], obj=cli_state, + cli, + ["devices", "bulk", "rename", "test_bulk_rename.csv"], + obj=cli_state, ) handler = bulk_processor.call_args[0][0] handler(guid="test", name="test-name-1") diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py index 5ddf60fdc..136e92395 100644 --- a/tests/cmds/test_high_risk_employee.py +++ b/tests/cmds/test_high_risk_employee.py @@ -405,8 +405,8 @@ def test_bulk_remove_risk_tags_uses_expected_arguments(runner, cli_state, mocker def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( mocker, runner, cli_state ): - cli_state.sdk.detectionlists.high_risk_employee.remove.side_effect = get_user_not_on_list_side_effect( - mocker, "high-risk-employee" + cli_state.sdk.detectionlists.high_risk_employee.remove.side_effect = ( + get_user_not_on_list_side_effect(mocker, "high-risk-employee") ) test_username = "test@example.com" result = runner.invoke( diff --git a/tests/cmds/test_legal_hold.py b/tests/cmds/test_legal_hold.py index 01888658e..b52d690fd 100644 --- a/tests/cmds/test_legal_hold.py +++ b/tests/cmds/test_legal_hold.py @@ -684,11 +684,23 @@ def test_search_events_when_no_results_outputs_no_results(runner, cli_state): f"{LEGAL_HOLD_COMMAND} remove-user --matter-id test-matter-id", "Missing option '-u' / '--username'.", ), - (f"{LEGAL_HOLD_COMMAND} add-user", "Missing option '-m' / '--matter-id'.",), - (f"{LEGAL_HOLD_COMMAND} remove-user", "Missing option '-m' / '--matter-id'.",), + ( + f"{LEGAL_HOLD_COMMAND} add-user", + "Missing option '-m' / '--matter-id'.", + ), + ( + f"{LEGAL_HOLD_COMMAND} remove-user", + "Missing option '-m' / '--matter-id'.", + ), (f"{LEGAL_HOLD_COMMAND} show", "Missing argument 'MATTER_ID'."), - (f"{LEGAL_HOLD_COMMAND} bulk add", "Error: Missing argument 'CSV_FILE'.",), - (f"{LEGAL_HOLD_COMMAND} bulk remove", "Error: Missing argument 'CSV_FILE'.",), + ( + f"{LEGAL_HOLD_COMMAND} bulk add", + "Error: Missing argument 'CSV_FILE'.", + ), + ( + f"{LEGAL_HOLD_COMMAND} bulk remove", + "Error: Missing argument 'CSV_FILE'.", + ), ], ) def test_legal_hold_command_when_missing_required_parameters_returns_error( diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index acc5d396a..b77e5a801 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -154,7 +154,8 @@ def test_create_profile_if_credentials_invalid_password_not_saved( ): mock_cliprofile_namespace.profile_exists.return_value = False result = runner.invoke( - cli, ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz"], + cli, + ["profile", "create", "-n", "foo", "-s", "bar", "-u", "baz"], ) assert "Password not stored!" in result.output assert not mock_cliprofile_namespace.set_password.call_count @@ -272,7 +273,8 @@ def test_update_profile_updates_default_profile( profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile runner.invoke( - cli, ["profile", "update", "-s", "bar", "-u", "baz", "--disable-ssl-errors"], + cli, + ["profile", "update", "-s", "bar", "-u", "baz", "--disable-ssl-errors"], ) mock_cliprofile_namespace.update_profile.assert_called_once_with( name, "bar", "baz", True @@ -286,7 +288,8 @@ def test_update_profile_updates_name_alone( profile.name = name mock_cliprofile_namespace.get_profile.return_value = profile runner.invoke( - cli, ["profile", "update", "-u", "baz", "--disable-ssl-errors"], + cli, + ["profile", "update", "-u", "baz", "--disable-ssl-errors"], ) mock_cliprofile_namespace.update_profile.assert_called_once_with( name, None, "baz", True diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index fdc887956..559753cd2 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -496,7 +496,9 @@ def test_search_and_send_to_when_given_begin_date_and_time_without_seconds_uses_ time = "15:33" runner.invoke( - cli, [*command, "--begin", f"{date} {time}"], obj=cli_state, + cli, + [*command, "--begin", f"{date} {time}"], + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] query_dict = dict(query) @@ -582,7 +584,9 @@ def test_search_and_send_to_when_end_date_is_before_begin_date_causes_exit( begin_date = get_test_date_str(days_ago=1) end_date = get_test_date_str(days_ago=3) result = runner.invoke( - cli, [*command, "--begin", begin_date, "--end", end_date], obj=cli_state, + cli, + [*command, "--begin", begin_date, "--end", end_date], + obj=cli_state, ) assert result.exit_code == 2 assert "'--begin': cannot be after --end date" in result.output @@ -637,7 +641,9 @@ def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_without_check search_all_file_events_success, ): result = runner.invoke( - cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, + cli, + [*command, "--use-checkpoint", "test", "--begin", "1h"], + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] query_dict = dict(query) @@ -657,7 +663,9 @@ def test_search_and_send_to_with_use_checkpoint_and_with_begin_and_with_stored_c search_all_file_events_success, ): result = runner.invoke( - cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, + cli, + [*command, "--use-checkpoint", "test", "--begin", "1h"], + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] query_dict = dict(query) @@ -679,7 +687,9 @@ def test_search_and_send_to_with_use_checkpoint_and_with_stored_checkpoint_as_ev search_all_file_events_success, ): result = runner.invoke( - cli, [*command, "--use-checkpoint", "test", "--begin", "1h"], obj=cli_state, + cli, + [*command, "--use-checkpoint", "test", "--begin", "1h"], + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] assert result.exit_code == 0 @@ -695,7 +705,9 @@ def test_search_and_send_to_when_given_invalid_exposure_type_causes_exit( runner, cli_state, command ): result = runner.invoke( - cli, [*command, "--begin", "1d", "-t", "NotValid"], obj=cli_state, + cli, + [*command, "--begin", "1d", "-t", "NotValid"], + obj=cli_state, ) assert result.exit_code == 2 assert "invalid choice: NotValid" in result.output @@ -709,7 +721,9 @@ def test_search_and_send_to_when_given_username_uses_username_filter( command = [*command, "--begin", "1h", "--c42-username", c42_username] runner.invoke( - cli, [*command], obj=cli_state, + cli, + [*command], + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] @@ -725,7 +739,9 @@ def test_search_and_send_to_when_given_actor_is_uses_username_filter( command = [*command, "--begin", "1h", "--actor", actor_name] runner.invoke( - cli, [*command], obj=cli_state, + cli, + [*command], + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] @@ -752,7 +768,9 @@ def test_search_and_send_to_when_given_sha256_uses_sha256_filter( sha_256 = "abcd12345" command = [*command, "--begin", "1h", "--sha256", sha_256] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.SHA256.is_in([sha_256]) @@ -778,7 +796,9 @@ def test_search_and_send_to_when_given_file_name_uses_file_name_filter( filename = "test.txt" command = [*command, "--begin", "1h", "--file-name", filename] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.FileName.is_in([filename]) @@ -792,7 +812,9 @@ def test_search_and_send_to_when_given_file_path_uses_file_path_filter( filepath = "C:\\Program Files" command = [*command, "--begin", "1h", "--file-path", filepath] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.FilePath.is_in([filepath]) @@ -806,7 +828,9 @@ def test_search_and_send_to_when_given_file_category_uses_file_category_filter( file_category = FileCategory.IMAGE command = [*command, "--begin", "1h", "--file-category", file_category] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.FileCategory.is_in([file_category]) @@ -845,7 +869,9 @@ def test_all_caps_file_category_choices_convert_to_filecategory_constant( ALL_CAPS_VALUE, ] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.FileCategory.is_in([camelCaseValue]) @@ -859,7 +885,9 @@ def test_search_and_send_to_when_given_process_owner_uses_process_owner_filter( process_owner = "root" command = [*command, "-b", "1h", "--process-owner", process_owner] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.ProcessOwner.is_in([process_owner]) @@ -873,7 +901,9 @@ def test_search_and_send_to_when_given_tab_url_uses_process_tab_url_filter( tab_url = "https://example.com" command = [*command, "--begin", "1h", "--tab-url", tab_url] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.TabURL.is_in([tab_url]) @@ -887,7 +917,9 @@ def test_search_and_send_to_when_given_exposure_types_uses_exposure_type_is_in_f exposure_type = "SharedViaLink" command = [*command, "--begin", "1h", "--type", exposure_type] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.ExposureType.is_in([exposure_type]) @@ -899,7 +931,9 @@ def test_search_and_send_to_when_given_include_non_exposure_does_not_include_exp runner, cli_state, command, search_all_file_events_success ): runner.invoke( - cli, [*command, "--begin", "1h", "--include-non-exposure"], obj=cli_state, + cli, + [*command, "--begin", "1h", "--include-non-exposure"], + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.ExposureType.exists() @@ -911,7 +945,9 @@ def test_search_and_send_to_when_not_given_include_non_exposure_includes_exposur runner, cli_state, command, search_all_file_events_success ): runner.invoke( - cli, [*command, "--begin", "1h"], obj=cli_state, + cli, + [*command, "--begin", "1h"], + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] filter_obj = f.ExposureType.exists() @@ -972,7 +1008,9 @@ def test_search_and_send_to_when_given_risk_indicator_uses_risk_indicator_filter risk_indicator = RiskIndicator.MessagingServiceUploads.SLACK command = [*command, "--begin", "1h", "--risk-indicator", risk_indicator] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] assert f.RiskIndicator.is_in([risk_indicator]) in query._filter_group_list @@ -1074,7 +1112,9 @@ def test_all_caps_risk_indicator_choices_convert_to_risk_indicator_string( ALL_CAPS_VALUE, ] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] assert f.RiskIndicator.is_in([string_value]) in query._filter_group_list @@ -1087,7 +1127,9 @@ def test_search_and_send_to_when_given_risk_severity_uses_risk_severity_filter( risk_severity = RiskSeverity.LOW command = [*command, "--begin", "1h", "--risk-severity", risk_severity] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] assert f.RiskSeverity.is_in([risk_severity]) in query._filter_group_list diff --git a/tests/cmds/test_trustedactivities.py b/tests/cmds/test_trustedactivities.py index c6177127d..df27af188 100644 --- a/tests/cmds/test_trustedactivities.py +++ b/tests/cmds/test_trustedactivities.py @@ -80,7 +80,9 @@ def trusted_activity_resource_id_not_found_error(custom_error): def test_create_calls_create_with_expected_params(runner, cli_state): command = ["trusted-activities", "create", "DOMAIN", "test-activity"] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) cli_state.sdk.trustedactivities.create.assert_called_once_with( "DOMAIN", "test-activity", description=None @@ -99,7 +101,9 @@ def test_create_with_optional_fields_calls_create_with_expected_params( "description", ] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) cli_state.sdk.trustedactivities.create.assert_called_once_with( "SLACK", "test-activity", description="description" @@ -170,10 +174,14 @@ def test_update_calls_update_with_expected_params(runner, cli_state): "test-activity-update", ] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) cli_state.sdk.trustedactivities.update.assert_called_once_with( - TEST_RESOURCE_ID, value="test-activity-update", description=None, + TEST_RESOURCE_ID, + value="test-activity-update", + description=None, ) @@ -190,7 +198,9 @@ def test_update_with_optional_fields_calls_update_with_expected_params( "update description", ] runner.invoke( - cli, command, obj=cli_state, + cli, + command, + obj=cli_state, ) cli_state.sdk.trustedactivities.update.assert_called_once_with( TEST_RESOURCE_ID, @@ -332,7 +342,9 @@ def test_bulk_add_trusted_activities_uses_expected_arguments( ) command = ["trusted-activities", "bulk", "create", "test_create.csv"] runner.invoke( - cli, command, obj=cli_state_with_user, + cli, + command, + obj=cli_state_with_user, ) assert bulk_processor.call_args[0][1] == [ {"type": "DOMAIN", "value": "test-domain", "description": ""}, @@ -356,7 +368,9 @@ def test_bulk_update_trusted_activities_uses_expected_arguments( ) command = ["trusted-activities", "bulk", "update", "test_update.csv"] runner.invoke( - cli, command, obj=cli_state_with_user, + cli, + command, + obj=cli_state_with_user, ) assert bulk_processor.call_args[0][1] == [ {"resource_id": "1", "value": "test-domain", "description": ""}, @@ -374,7 +388,9 @@ def test_bulk_remove_trusted_activities_uses_expected_arguments_when_no_header( csv.writelines(["1\n", "2\n"]) command = ["trusted-activities", "bulk", "remove", "test_remove.csv"] runner.invoke( - cli, command, obj=cli_state_with_user, + cli, + command, + obj=cli_state_with_user, ) assert bulk_processor.call_args[0][1] == [ {"resource_id": "1"}, diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 28151e34c..418dd76e4 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -298,22 +298,22 @@ def change_org_success(cli_state, change_org_response): @pytest.fixture def add_alias_success(mocker, cli_state): - cli_state.sdk.detectionlists.add_user_cloud_alias.return_value = create_mock_response( - mocker + cli_state.sdk.detectionlists.add_user_cloud_alias.return_value = ( + create_mock_response(mocker) ) @pytest.fixture def add_alias_limit_failure(mocker, cli_state): - cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = Py42CloudAliasLimitExceededError( - create_mock_http_error(mocker) + cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = ( + Py42CloudAliasLimitExceededError(create_mock_http_error(mocker)) ) @pytest.fixture def remove_alias_success(mocker, cli_state): - cli_state.sdk.detectionlists.remove_user_cloud_alias.return_value = create_mock_response( - mocker + cli_state.sdk.detectionlists.remove_user_cloud_alias.return_value = ( + create_mock_response(mocker) ) @@ -511,7 +511,9 @@ def test_list_prints_expected_data_if_include_roles( def test_show_calls_get_by_username_with_expected_params(runner, cli_state): runner.invoke( - cli, ["users", "show", "test.username@example.com"], obj=cli_state, + cli, + ["users", "show", "test.username@example.com"], + obj=cli_state, ) cli_state.sdk.users.get_by_username.assert_called_once_with( "test.username@example.com", incRoles=True @@ -521,7 +523,9 @@ def test_show_calls_get_by_username_with_expected_params(runner, cli_state): def test_show_prints_expected_data(runner, cli_state, get_users_response): cli_state.sdk.users.get_by_username.return_value = get_users_response result = runner.invoke( - cli, ["users", "show", "test.username@example.com"], obj=cli_state, + cli, + ["users", "show", "test.username@example.com"], + obj=cli_state, ) assert "test.username@example.com" in result.output assert "911162111513111325" in result.output @@ -945,7 +949,9 @@ def _update(user_id, *args, **kwargs): with open("test_bulk_update.csv", "w") as csv: csv.writelines(lines) runner.invoke( - cli, ["users", "bulk", "update", "test_bulk_update.csv"], obj=cli_state, + cli, + ["users", "bulk", "update", "test_bulk_update.csv"], + obj=cli_state, ) handler = bulk_processor.call_args[0][0] handler( @@ -1033,7 +1039,9 @@ def _get(username, *args, **kwargs): with open("test_bulk_move.csv", "w") as csv: csv.writelines(lines) runner.invoke( - cli, ["users", "bulk", "move", "test_bulk_move.csv"], obj=cli_state, + cli, + ["users", "bulk", "move", "test_bulk_move.csv"], + obj=cli_state, ) handler = bulk_processor.call_args[0][0] handler(username="test@example.com", org_id="test") @@ -1186,7 +1194,9 @@ def test_bulk_add_roles_uses_expected_arguments(runner, mocker, cli_state_with_u ) command = ["users", "bulk", "add-roles", "test_bulk_add_roles.csv"] runner.invoke( - cli, command, obj=cli_state_with_user, + cli, + command, + obj=cli_state_with_user, ) assert bulk_processor.call_args[0][1] == [ {"username": TEST_USERNAME, "role_name": TEST_ROLE_NAME, "role added": "False"}, @@ -1241,7 +1251,8 @@ def _get(username, *args, **kwargs): handler = bulk_processor.call_args[0][0] handler( - username="test@example.com", role_name=TEST_ROLE_NAME, + username="test@example.com", + role_name=TEST_ROLE_NAME, ) handler(username="not.test@example.com", role_name=TEST_ROLE_NAME) assert worker_stats.increment_total_errors.call_count == 1 @@ -1256,7 +1267,9 @@ def test_bulk_remove_roles_uses_expected_arguments(runner, mocker, cli_state_wit ) command = ["users", "bulk", "remove-roles", "test_bulk_remove_roles.csv"] runner.invoke( - cli, command, obj=cli_state_with_user, + cli, + command, + obj=cli_state_with_user, ) assert bulk_processor.call_args[0][1] == [ { @@ -1319,7 +1332,8 @@ def _get(username, *args, **kwargs): ) handler = bulk_processor.call_args[0][0] handler( - username="test@example.com", role_name=TEST_ROLE_NAME, + username="test@example.com", + role_name=TEST_ROLE_NAME, ) handler(username="not.test@example.com", role_name=TEST_ROLE_NAME) assert worker_stats.increment_total_errors.call_count == 1 @@ -1371,7 +1385,8 @@ def test_orgs_list_prints_all_data_fields_when_not_table_format( def test_orgs_show_calls_orgs_get_by_uid_with_expected_params( - runner, cli_state, + runner, + cli_state, ): runner.invoke(cli, ["users", "orgs", "show", TEST_ORG_UID], obj=cli_state) cli_state.sdk.orgs.get_by_uid.assert_called_once_with(TEST_ORG_UID) @@ -1384,7 +1399,9 @@ def test_orgs_show_exits_and_returns_error_if_uid_arg_not_provided(runner, cli_s def test_orgs_show_prints_expected_data( - runner, cli_state, get_org_success, + runner, + cli_state, + get_org_success, ): result = runner.invoke(cli, ["users", "orgs", "show", TEST_ORG_UID], obj=cli_state) assert "9087" in result.output @@ -1400,7 +1417,9 @@ def test_orgs_show_prints_expected_data( def test_orgs_show_prints_all_data_fields_when_not_table_format( - runner, cli_state, get_org_success, + runner, + cli_state, + get_org_success, ): result = runner.invoke( cli, ["users", "orgs", "show", TEST_ORG_UID, "-f", "JSON"], obj=cli_state @@ -1552,7 +1571,9 @@ def test_bulk_add_alias_uses_expected_arguments(runner, mocker, cli_state): with open("test_add_alias.csv", "w") as csv: csv.writelines(["username,alias\n", f"{TEST_USERNAME},{TEST_ALIAS}\n"]) runner.invoke( - cli, ["users", "bulk", "add-alias", "test_add_alias.csv"], obj=cli_state, + cli, + ["users", "bulk", "add-alias", "test_add_alias.csv"], + obj=cli_state, ) assert bulk_processor.call_args[0][1] == [ {"username": TEST_USERNAME, "alias": TEST_ALIAS, "alias added": "False"} @@ -1568,7 +1589,9 @@ def test_bulk_add_alias_ignores_blank_lines(runner, mocker, cli_state): ["username,alias\n\n\n", f"{TEST_USERNAME},{TEST_ALIAS}\n\n\n"] ) runner.invoke( - cli, ["users", "bulk", "add-alias", "test_add_alias.csv"], obj=cli_state, + cli, + ["users", "bulk", "add-alias", "test_add_alias.csv"], + obj=cli_state, ) assert bulk_processor.call_args[0][1] == [ {"username": TEST_USERNAME, "alias": TEST_ALIAS, "alias added": "False"} @@ -1592,7 +1615,9 @@ def _get(username, *args, **kwargs): with open("test_add_alias.csv", "w") as csv: csv.writelines(lines) runner.invoke( - cli, ["users", "bulk", "add-alias", "test_add_alias.csv"], obj=cli_state, + cli, + ["users", "bulk", "add-alias", "test_add_alias.csv"], + obj=cli_state, ) handler = bulk_processor.call_args[0][0] handler(username="test@example.com", alias=TEST_ALIAS) diff --git a/tests/integration/test_auditlogs.py b/tests/integration/test_auditlogs.py index c7eedc20b..c8a09d247 100644 --- a/tests/integration/test_auditlogs.py +++ b/tests/integration/test_auditlogs.py @@ -47,7 +47,8 @@ def test_auditlogs_search_command_with_short_hand_begin_returns_success_return_c @pytest.mark.integration def test_auditlogs_search_command_with_full_begin_returns_success_return_code( - runner, integration_test_profile, + runner, + integration_test_profile, ): command = f"audit-logs search --begin '{begin_date_str}'" assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/logger/test_init.py b/tests/logger/test_init.py index 109a181c6..b1948da93 100644 --- a/tests/logger/test_init.py +++ b/tests/logger/test_init.py @@ -29,7 +29,10 @@ def init_socket_mock(mocker): def fresh_syslog_handler(init_socket_mock): # Set handlers to empty list so it gets initialized each test get_logger_for_server( - "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None, + "example.com", + ServerProtocol.TCP, + SendToFileEventsOutputFormat.CEF, + None, ).handlers = [] init_socket_mock.call_count = 0 @@ -134,7 +137,10 @@ def test_get_logger_for_server_when_hostname_includes_port_constructs_handler_wi "example.com:999", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None ) no_priority_syslog_handler.assert_called_once_with( - "example.com", 999, ServerProtocol.TCP, None, + "example.com", + 999, + ServerProtocol.TCP, + None, ) diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 453863973..4031de51f 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -179,7 +179,9 @@ def func_for_bulk(test1, test2): assert (None, "foo") in processed_rows assert ("bar", None) in processed_rows - def test_processor_stores_results_in_stats(self,): + def test_processor_stores_results_in_stats( + self, + ): def func_for_bulk(test): return test diff --git a/tests/test_file_readers.py b/tests/test_file_readers.py index 91928c75d..23e5d8f2b 100644 --- a/tests/test_file_readers.py +++ b/tests/test_file_readers.py @@ -68,7 +68,8 @@ def test_read_csv_when_some_but_not_all_required_headers_present_raises(runner): @pytest.mark.parametrize( - "encoding", ["utf8", "utf16", "latin_1"], + "encoding", + ["utf8", "utf16", "latin_1"], ) def test_read_csv_reads_various_encodings_automatically(runner, encoding): with runner.isolated_filesystem(): @@ -90,7 +91,8 @@ def test_AutoDecodedFile_raises_expected_exception_when_file_not_exists(runner): @pytest.mark.parametrize( - "encoding", ["utf8", "utf16", "latin_1"], + "encoding", + ["utf8", "utf16", "latin_1"], ) def test_FileOrString_arg_handles_various_encodings_automatically(runner, encoding): test_data = '{"tést": "dåta"}' diff --git a/tests/test_magic_date_type.py b/tests/test_magic_date_type.py index 86a8446f4..4e1ac5a74 100644 --- a/tests/test_magic_date_type.py +++ b/tests/test_magic_date_type.py @@ -34,7 +34,8 @@ def test_when_given_date_str_parses_successfully(self): assert actual == expected @pytest.mark.parametrize( - "param", [begin_date_str_with_time, begin_date_str_with_t_time], + "param", + [begin_date_str_with_time, begin_date_str_with_t_time], ) def test_when_given_date_str_with_time_parses_successfully(self, param): actual = self.convert(param) @@ -86,7 +87,9 @@ def test_when_given_date_str_parses_successfully(self): expected = utc(datetime.strptime(begin_date_str, "%Y-%m-%d")) assert actual == expected - def test_when_given_date_str_with_time_parses_successfully(self,): + def test_when_given_date_str_with_time_parses_successfully( + self, + ): actual = self.convert(begin_date_str_with_time) expected = utc(datetime.strptime(begin_date_str_with_time, "%Y-%m-%d %H:%M:%S")) assert actual == expected From d7d3575a6856654db3ba9255c20a3088c1f4fa89 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Mon, 4 Apr 2022 09:52:05 -0500 Subject: [PATCH 305/349] prep 1.13.0 release (#361) --- CHANGELOG.md | 6 ++++-- src/code42cli/__version__.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 920348e0d..70e6b1a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.13.0 - 2022-04-04 ### Added -- `departing-employee bulk remove` and `high-risk-employee bulk remove` commands now accept an optional header, as well as extraneous columns if a header is provided. + +- `departing-employee bulk remove` and `high-risk-employee bulk remove` commands now accept CSVs with an optional header, as well as extraneous columns if a header is provided. - Added `devices rename` and `devices bulk rename` commands to rename devices. - *Note: Incydr devices cannot be renamed.* - Added the following commands for managing users' cloud aliases: @@ -26,6 +27,7 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Fixed - Vulnerability in `ipython` dependency. See [CVE-2022-21699](https://nvd.nist.gov/vuln/detail/CVE-2022-21699). + ## 1.12.0 - 2021-12-13 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 438a38d1e..9a34ccc9f 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.12.1" +__version__ = "1.13.0" From 017517bcdd87f9a535ac493b0a4764a6cd3d2c95 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 4 Apr 2022 12:02:39 -0500 Subject: [PATCH 306/349] try fixing cla assistant (#362) --- .github/workflows/cla.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 4546a315c..d39ed40fb 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -12,7 +12,7 @@ jobs: - name: "CLA Assistant" if: (github.event.comment.body == 'recheckcla' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' # Alpha Release - uses: cla-assistant/github-action@v2.13.0 + uses: cla-assistant/cla-assistant@v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret From f5e776a43251da077698f70bb70c94bddb0ee6f3 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 4 Apr 2022 14:53:57 -0500 Subject: [PATCH 307/349] test cla (#363) * test cla * revert back to original version * revert back to original version --- .github/workflows/cla.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index d39ed40fb..b1e567099 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -12,7 +12,7 @@ jobs: - name: "CLA Assistant" if: (github.event.comment.body == 'recheckcla' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' # Alpha Release - uses: cla-assistant/cla-assistant@v2.13.0 + uses: cla-assistant/cla-assistant@v2.0.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret From d44f5835d3de1b36fa9d6aa7b32e1c8b8ed692e4 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Fri, 8 Apr 2022 09:16:24 -0500 Subject: [PATCH 308/349] test cla again (#364) * test cla * use latest version --- .github/workflows/cla.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index b1e567099..d39ed40fb 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -12,7 +12,7 @@ jobs: - name: "CLA Assistant" if: (github.event.comment.body == 'recheckcla' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' # Alpha Release - uses: cla-assistant/cla-assistant@v2.0.1 + uses: cla-assistant/cla-assistant@v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret From ba0aa54a41a433cab6f4739c8d606a184336fe51 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Mon, 25 Apr 2022 15:18:11 -0500 Subject: [PATCH 309/349] Bugfix/update doc dependencies (#365) * update doc dependencies * fix sphinx * update myst-parser --- .readthedocs.yaml | 22 ++++++++++++++++++++++ CONTRIBUTING.md | 8 ++++---- docs/conf.py | 6 ++++-- setup.py | 8 +++++++- tox.ini | 4 ++-- 5 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..cc1c33d80 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,22 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally build your docs in additional formats such as PDF and ePub +formats: all + +python: + version: 3.7 + install: + - method: pip + path: . + extra_requirements: + - dev + - docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd2e2c3a9..5527425d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,12 +45,12 @@ eval "$(pyenv virtualenv-init -)" Then, create your virtual environment. ```bash -pyenv install 3.6.10 -pyenv virtualenv 3.6.10 code42cli +pyenv install 3.9.10 +pyenv virtualenv 3.9.10 code42cli pyenv activate code42cli ``` -**Note**: The CLI supports pythons versions 3.6 through 3.9 - any versions within that range should work for your virtual environment. Use `pyenv --versions` to see all versions available for install. There are some known issues installing python 3.6 with pyenv on certain OS. +**Note**: The CLI supports pythons versions 3.6 through 3.9 for end users. However due to some of the build dependencies, you'll need a version >=3.7 for your virtual environment. Use `pyenv --versions` to see all versions available for install. There are some known issues installing python 3.6 with pyenv on certain OS. Use `source deactivate` to exit the virtual environment and `pyenv activate code42cli` to reactivate it. @@ -174,7 +174,7 @@ tox -e docs To build and run the documentation locally, run the following from the `docs` directory: ```bash -pip install sphinx myst_parser sphinx_rtd_theme +pip install sphinx myst-parser sphinx_rtd_theme make html ``` diff --git a/docs/conf.py b/docs/conf.py index 31a324dc1..94ced0098 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,7 +17,7 @@ # -- Project information ----------------------------------------------------- project = "code42cli" -copyright = "2020, Code42 Software" +copyright = "2022, Code42 Software" author = "Code42 Software" # The short X.Y version @@ -30,7 +30,7 @@ # If your documentation needs a minimal Sphinx version, state it here. # -# needs_sphinx = '1.0' +needs_sphinx = "4.4.0" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -71,6 +71,8 @@ # The name of the Pygments (syntax highlighting) style to use. pygments_style = None +# generate header anchors +myst_heading_anchors = 4 # -- Options for HTML output ------------------------------------------------- diff --git a/setup.py b/setup.py index 7b8a61be2..c67123ee7 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,13 @@ "pytest-cov==2.10.0", "pytest-mock==2.0.0", "tox>=3.17.1", - ] + ], + "docs": [ + "sphinx==4.4.0", + "myst-parser==0.16", + "sphinx_rtd_theme==1.0.0", + "sphinx-click", + ], }, classifiers=[ "Intended Audience :: Developers", diff --git a/tox.ini b/tox.ini index 3557a4d73..cbcfef1d6 100644 --- a/tox.ini +++ b/tox.ini @@ -25,8 +25,8 @@ commands = [testenv:docs] deps = sphinx == 4.4.0 - myst_parser == 0.16 - sphinx_rtd_theme == 0.5.2 + myst-parser == 0.17.2 + sphinx_rtd_theme == 1.0.0 sphinx-click whitelist_externals = bash From 4902e573e1663a32b43721b22f862ca9c1fc79e7 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 26 Apr 2022 12:27:22 -0500 Subject: [PATCH 310/349] import IPython only when `code42 shell` is actually run (#366) --- src/code42cli/cmds/shell.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/code42cli/cmds/shell.py b/src/code42cli/cmds/shell.py index bd33d681a..8eb1fdf67 100644 --- a/src/code42cli/cmds/shell.py +++ b/src/code42cli/cmds/shell.py @@ -1,5 +1,4 @@ import click -import IPython from code42cli import BANNER from code42cli.options import sdk_options @@ -9,4 +8,6 @@ @sdk_options() def shell(state): """Open an IPython shell with py42 initialized as `sdk`.""" + import IPython + IPython.embed(colors="Neutral", banner1=BANNER, user_ns={"sdk": state.sdk}) From b8c12c0a2f286e6233563cc61ea8074c9a1679f4 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 10 May 2022 12:31:51 -0500 Subject: [PATCH 311/349] Feature/watchlists (#367) * initial watchlists commands * error handling * add `bulk generate-template` * require id or type option on `list-users` * unused import * mark departing_employee & high_risk as deprecated * silence py42 deprecation warnings * add deprecation_warning util func * add user-risk-profile updates to `code42 users` cmd group * fix bulk header names * watchlists cmd tests * fix existing alias cmd tests * rename commands from add>update * actually use --clear option on risk-profile update cmds * add append-note logic to bulk risk-profile update * not handling user_id in csv * new user risk profile cmd tests * PR feedback & style * brackets around user metavar * documentation updates * use get_watchlist_members instead of get_included_users * switch tests to use get_all_watchlist_members * change list-users > list-members in user guide * appease flake8 * add --only-included-users option to `list-members` cmd * move f-string docstrings to help params * document --only-included-users option * add > remove in test * changelog --- CHANGELOG.md | 20 + docs/commands.md | 10 +- docs/commands/watchlists.rst | 3 + docs/guides.md | 6 +- docs/userguides/detectionlists.md | 12 +- docs/userguides/users.md | 37 +- docs/userguides/watchlists.md | 64 +++ src/code42cli/click_ext/groups.py | 6 + src/code42cli/cmds/departing_employee.py | 31 +- src/code42cli/cmds/high_risk_employee.py | 64 ++- src/code42cli/cmds/users.py | 155 +++++- src/code42cli/cmds/watchlists.py | 262 +++++++++ src/code42cli/main.py | 5 + src/code42cli/util.py | 4 + tests/cmds/test_users.py | 222 +++++++- tests/cmds/test_watchlists.py | 666 +++++++++++++++++++++++ 16 files changed, 1486 insertions(+), 81 deletions(-) create mode 100644 docs/commands/watchlists.rst create mode 100644 docs/userguides/watchlists.md create mode 100644 src/code42cli/cmds/watchlists.py create mode 100644 tests/cmds/test_watchlists.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e6b1a76..a6115ad4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. + +## Unreleased + +### Added + +- `watchlists` command group for interacting with watchlists. + - `watchlists add` for adding users to a watchlist + - `watchlists remove` for removing users from a watchlist + - `watchlists list` for listing existing watchlists + - `watchlists list-members` for listing users who are members of a given watchlist + - `watchlist bulk add|remove` for adding/removing multiple users via CSV file + +- `users update-start-date` command to add/modify the "start date" property of a User's risk profile. +- `users update-departure-date` command to add/modify the "end date" property of a User's risk profile. +- `users update-risk-profile-notes` command to add/modify the "notes" property of a User's risk profile. + +### Deprecated + +- `departing-employee` and `high-risk-employee` command groups. These actions have been replaced by the `watchlists` command group. + ## 1.13.0 - 2022-04-04 ### Added diff --git a/docs/commands.md b/docs/commands.md index 25050308c..4ebf35098 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,25 +10,27 @@ Alerts Audit Logs Cases - Departing Employee Devices - High Risk Employee Legal Hold Profile Security Data Trusted Activities Users + Watchlists + (DEPRECATED) Departing Employee + (DEPRECATED) High Risk Employee ``` * [Alert Rules](commands/alertrules.rst) * [Alerts](commands/alerts.rst) * [Audit Logs](commands/auditlogs.rst) * [Cases](commands/cases.rst) -* [Departing Employee](commands/departingemployee.rst) * [Devices](commands/devices.rst) -* [High Risk Employee](commands/highriskemployee.rst) * [Legal Hold](commands/legalhold.rst) * [Profile](commands/profile.rst) * [Security Data](commands/securitydata.rst) * [Trusted Activities](commands/trustedactivities.rst) * [Users](commands/users.rst) +* [Watchlists](commands/watchlists.rst) +* [(DEPRECATED) Departing Employee](commands/departingemployee.rst) +* [(DEPRECATED) High Risk Employee](commands/highriskemployee.rst) diff --git a/docs/commands/watchlists.rst b/docs/commands/watchlists.rst new file mode 100644 index 000000000..1b48ba246 --- /dev/null +++ b/docs/commands/watchlists.rst @@ -0,0 +1,3 @@ +.. click:: code42cli.cmds.watchlists:watchlists + :prog: watchlists + :nested: full diff --git a/docs/guides.md b/docs/guides.md index 9b5a9310c..489b87f87 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -9,7 +9,6 @@ Get started with the Code42 command-line interface (CLI) Configure a profile Ingest data into a SIEM - Manage detection list users Manage legal hold users Clean up your environment by deactivating devices Write custom extension scripts using the Code42 CLI and Py42 @@ -18,12 +17,13 @@ Configure alert rules Add and manage cases Perform bulk actions + Manage watchlist members + (DEPRECATED) Manage detection list users ``` * [Get started with the Code42 command-line interface (CLI)](userguides/gettingstarted.md) * [Configure a profile](userguides/profile.md) * [Ingest data into a SIEM](userguides/siemexample.md) -* [Manage detection list users](userguides/detectionlists.md) * [Manage legal hold users](userguides/legalhold.md) * [Clean up your environment by deactivating devices](userguides/deactivatedevices.md) * [Write custom extension scripts using the Code42 CLI and Py42](userguides/extensions.md) @@ -32,3 +32,5 @@ * [Configure alert rules](userguides/alertrules.md) * [Add and manage cases](userguides/cases.md) * [Perform bulk actions](userguides/bulkcommands.md) +* [Manage watchlist members](userguides/watchlists.md) +* [(DEPRECATED) Manage detection list users](userguides/detectionlists.md) diff --git a/docs/userguides/detectionlists.md b/docs/userguides/detectionlists.md index cabc2b1f8..ce96c5470 100644 --- a/docs/userguides/detectionlists.md +++ b/docs/userguides/detectionlists.md @@ -1,4 +1,14 @@ -# Manage Detection List Users +# (DEPRECATED) Manage Detection List Users + +```{eval-rst} +.. note:: + + Detection Lists have been replaced by Watchlists. + + Functionality for adding users to Departing Employee and High Risk Employee categories has been migrated to the :code:`code42 watchlists` command group. + + Functionality for listing and managing User Risk Profiles (e.g. adding Cloud Aliases, Notes, and Start/End dates to a user profile) has been migrated to the :code:`code42 users` command group. +``` Use the `departing-employee` commands to add employees to or remove employees from the Departing Employees list. Use the `high-risk-employee` commands to add employees to or remove employees from the High Risk list, or update risk tags for those users. diff --git a/docs/userguides/users.md b/docs/userguides/users.md index a422a9bf4..f090471f1 100644 --- a/docs/userguides/users.md +++ b/docs/userguides/users.md @@ -34,6 +34,34 @@ code42 users add-role --username "sean.cassidy@example.com" --role-name "Desktop Similarly, use the `remove-role` command to remove a role from a user. +## Manage User Risk Profile info + +To set a start or end/departure date on a User's profile (useful for users on the "New Hire" and "Departing" Watchlists): + +```bash +code42 users update-start-date 2020-03-10 user@example.com + +code42 users update-departure-date 2022-06-20 user@example.com +``` + +To clear the value of start_date/end_date on a User's profile, use the `--clear` option to the above commands: + +```bash +code42 users update-departure-date --clear user@example.com +``` + +To update a User's Risk Profile notes field: + +```bash +code42 users update-risk-profile-notes user@example.com "New note text" +``` + +By default, the note text will overwrite notes are already on the profile. To keep existing note data, use the `--append` option: + +```bash +code42 users update-risk-profile-notes user@example.com "Additional note text" --append +``` + ## Deactivate a User You can deactivate a user with the following command: @@ -73,17 +101,18 @@ Alternatively, to move multiple users between organizations, fill out the `move` code42 users bulk move bulk-command.csv ``` -## Get CSV Template +## Get CSV Template for bulk commands -The following command generates a CSV template to either update users' data, or move users between organizations. The csv file is saved to the current working directory. +The following command generates a CSV template for each of the available bulk user commands. The CSV file is saved to the current working directory. ```bash -code42 trusted-activities bulk generate-template [update|move] +code42 users bulk generate-template [update|move|add-alias|remove-alias|update-risk-profile] ``` Once generated, fill out and use each of the CSV templates with their respective bulk commands. ```bash -code42 trusted-activities bulk [update|move|reactivate|deactivate] bulk-command.csv +code42 users bulk [update|move|deactivate|reactivate|add-alias|remove-alias|update-risk-profile] bulk-command.csv ``` + A CSV with a `username` column and a single username on each new line is used for the `reactivate` and `deactivate` bulk commands. These commands are not available as options for `generate-template`. Learn more about [Managing Users](../commands/users.md). diff --git a/docs/userguides/watchlists.md b/docs/userguides/watchlists.md new file mode 100644 index 000000000..3dbaca04b --- /dev/null +++ b/docs/userguides/watchlists.md @@ -0,0 +1,64 @@ +# Manage watchlist members + +## List created watchlists + +To list all the watchlists active in your Code42 environment, run: + +```bash +code42 watchlists list +``` + +## List all members of a watchlist + +You can list watchlists either by their Type: + +```bash +code42 watchlists list-members --watchlist-type DEPARTING_EMPLOYEE +``` + +or by their ID (get watchlist IDs from `code42 watchlist list` output): + +```bash +code42 watchlists list-members --watchlist-id 6e6c5acc-2568-4e5f-8324-e73f2811fa7c +``` + +A "member" of a watchlist is any user that the watchlist alerting rules apply to. Users can be members of a watchlist +either by being explicitly added (via console or `code42 watchlists add [USER_ID|USERNAME]`), but they can also be +implicitly included based on some user profile property (like working in a specific department). To get a list of only +those "members" who have been explicitly added (and thus can be removed via the `code42 watchlists remove [USER_ID|USERNAME]` +command), add the `--only-included-users` option to `list-members`. + +## Add or remove a single user from watchlist membership + +A user can be added to a watchlist using either the watchlist ID or Type, just like listing watchlists, and the user +can be identified either by their user_id or their username: + +```bash +code42 watchlist add --watchlist-type NEW_EMPLOYEE 9871230 +``` + +```bash +code42 watchlist add --watchlist-id 6e6c5acc-2568-4e5f-8324-e73f2811fa7c user@example.com +``` + +## Bulk adding/removing users from watchlists + +The bulk watchlist commands read input from a CSV file. + +Like the individual commands, they can take either a user_id/username or watchlist_id/watchlist_type to identify who +to add to which watchlist. Because of this flexibility, the CSV does require a header row identifying each column. + +You can generate a template CSV with the correct header values using the command: + +```bash +code42 watchlists bulk generate-template [add|remove] +``` + +If both username and user_id are provided in the CSV row, the user_id value will take precedence. If watchlist_type and watchlist_id columns +are both provided, the watchlist_id will take precedence. + +## Add Watchlist-related metadata to User's Profile + +Some Watchlists store related metadata to the watchlist member on the User Risk Profile. For example, when adding a user +to the Departing Watchlist in the Code42 admin console, you can specify a "departure date" for the user. These values +can be set/updated from the CLI under the [Users command group](./users.md#manage-user-risk-profile-info) diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index 6f81ac67f..dadb59e78 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -17,6 +17,7 @@ from py42.exceptions import Py42InvalidRuleOperationError from py42.exceptions import Py42InvalidUsernameError from py42.exceptions import Py42LegalHoldNotFoundOrPermissionDeniedError +from py42.exceptions import Py42NotFoundError from py42.exceptions import Py42OrgNotFoundError from py42.exceptions import Py42TrustedActivityConflictError from py42.exceptions import Py42TrustedActivityIdNotFound @@ -25,6 +26,8 @@ from py42.exceptions import Py42UserAlreadyAddedError from py42.exceptions import Py42UsernameMustBeEmailError from py42.exceptions import Py42UserNotOnListError +from py42.exceptions import Py42UserRiskProfileNotFound +from py42.exceptions import Py42WatchlistNotFound from code42cli.errors import Code42CLIError from code42cli.errors import LoggedCLIError @@ -90,6 +93,9 @@ def invoke(self, ctx): Py42TrustedActivityIdNotFound, Py42CloudAliasLimitExceededError, Py42CloudAliasCharacterLimitExceededError, + Py42UserRiskProfileNotFound, + Py42WatchlistNotFound, + Py42NotFoundError, ) as err: msg = err.args[0] self.logger.log_error(msg) diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 9a729104d..50c432163 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -17,6 +17,7 @@ from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.options import sdk_options +from code42cli.util import deprecation_warning def _get_filter_choices(): @@ -24,6 +25,8 @@ def _get_filter_choices(): return get_choices(filters) +DEPRECATION_TEXT = "(DEPRECATED): Use `code42 watchlists` commands instead." + DATE_FORMAT = "%Y-%m-%d" filter_option = click.option( "--filter", @@ -34,19 +37,18 @@ def _get_filter_choices(): ) -@click.group(cls=OrderedGroup) +@click.group(cls=OrderedGroup, help=f"{DEPRECATION_TEXT}\n\nAdd and remove employees from the Departing Employees detection list.") @sdk_options(hidden=True) def departing_employee(state): - """Add and remove employees from the Departing Employees detection list.""" pass -@departing_employee.command("list") +@departing_employee.command("list", help=f"{DEPRECATION_TEXT}\n\nLists the users on the Departing Employees list.") @sdk_options() @format_option @filter_option def _list(state, format, filter): - """Lists the users on the Departing Employees list.""" + deprecation_warning(DEPRECATION_TEXT) employee_generator = _get_departing_employees(state.sdk, filter) list_employees( employee_generator, @@ -55,7 +57,7 @@ def _list(state, format, filter): ) -@departing_employee.command() +@departing_employee.command(help=f"{DEPRECATION_TEXT}\n\nAdd a user to the Departing Employees detection list.") @username_arg @click.option( "--departure-date", @@ -66,22 +68,22 @@ def _list(state, format, filter): @notes_option @sdk_options() def add(state, username, cloud_alias, departure_date, notes): - """Add a user to the Departing Employees detection list.""" + + deprecation_warning(DEPRECATION_TEXT) _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) -@departing_employee.command() +@departing_employee.command(help=f"{DEPRECATION_TEXT}\n\nRemove a user from the Departing Employees detection list.") @username_arg @sdk_options() def remove(state, username): - """Remove a user from the Departing Employees detection list.""" + deprecation_warning(DEPRECATION_TEXT) _remove_departing_employee(state.sdk, username) -@departing_employee.group(cls=OrderedGroup) +@departing_employee.group(cls=OrderedGroup, help=f"{DEPRECATION_TEXT}\n\nTools for executing bulk departing employee actions.") @sdk_options(hidden=True) def bulk(state): - """Tools for executing bulk departing employee actions.""" pass @@ -101,12 +103,13 @@ def bulk(state): @bulk.command( name="add", - help="Bulk add users to the departing employees detection list using a CSV file with " - f"format: {','.join(DEPARTING_EMPLOYEE_CSV_HEADERS)}.", + help=f"{DEPRECATION_TEXT}\n\nBulk add users to the departing employees detection list using " + f"a CSV file with format: {','.join(DEPARTING_EMPLOYEE_CSV_HEADERS)}.", ) @read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) @sdk_options() def bulk_add(state, csv_rows): + deprecation_warning(DEPRECATION_TEXT) sdk = state.sdk # Force initialization of py42 to only happen once. def handle_row(username, cloud_alias, departure_date, notes): @@ -131,11 +134,13 @@ def handle_row(username, cloud_alias, departure_date, notes): @bulk.command( name="remove", - help=f"Bulk remove users from the departing employees detection list using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.", + help=f"{DEPRECATION_TEXT}\n\nBulk remove users from the departing employees detection list " + f"using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.", ) @read_csv_arg(headers=REMOVE_EMPLOYEE_HEADERS) @sdk_options() def bulk_remove(state, csv_rows): + deprecation_warning(DEPRECATION_TEXT) sdk = state.sdk def handle_row(username): diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py index 152effc7c..a5153d1b2 100644 --- a/src/code42cli/cmds/high_risk_employee.py +++ b/src/code42cli/cmds/high_risk_employee.py @@ -20,6 +20,9 @@ from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.options import sdk_options +from code42cli.util import deprecation_warning + +DEPRECATION_TEXT = "(DEPRECATED): Use `code42 watchlists` commands instead." def _get_filter_choices(): @@ -45,65 +48,79 @@ def _get_filter_choices(): ) -@click.group(cls=OrderedGroup) +@click.group( + cls=OrderedGroup, + help=f"{DEPRECATION_TEXT}\n\nAdd and remove employees from the High Risk Employees detection list.", +) @sdk_options(hidden=True) def high_risk_employee(state): - """Add and remove employees from the High Risk Employees detection list.""" pass -@high_risk_employee.command("list") +@high_risk_employee.command( + "list", + help=f"{DEPRECATION_TEXT}\n\nLists the employees on the High Risk Employee list.", +) @sdk_options() @format_option @filter_option def _list(state, format, filter): - """Lists the employees on the High Risk Employee list.""" - + deprecation_warning(DEPRECATION_TEXT) employee_generator = _get_high_risk_employees(state.sdk, filter) list_employees(employee_generator, format) -@high_risk_employee.command() +@high_risk_employee.command( + help=f"{DEPRECATION_TEXT}\n\nAdd a user to the high risk employees detection list." +) @cloud_alias_option @notes_option @risk_tag_option @username_arg @sdk_options() def add(state, username, cloud_alias, risk_tag, notes): - """Add a user to the high risk employees detection list.""" + deprecation_warning(DEPRECATION_TEXT) _add_high_risk_employee(state.sdk, username, cloud_alias, risk_tag, notes) -@high_risk_employee.command() +@high_risk_employee.command( + help=f"{DEPRECATION_TEXT}\n\nRemove a user from the high risk employees detection list." +) @username_arg @sdk_options() def remove(state, username): - """Remove a user from the high risk employees detection list.""" + deprecation_warning(DEPRECATION_TEXT) _remove_high_risk_employee(state.sdk, username) -@high_risk_employee.command() +@high_risk_employee.command( + help=f"{DEPRECATION_TEXT}\n\nAssociates risk tags with a user." +) @username_arg @risk_tag_option @sdk_options() def add_risk_tags(state, username, risk_tag): - """Associates risk tags with a user.""" + deprecation_warning(DEPRECATION_TEXT) _add_risk_tags(state.sdk, username, risk_tag) -@high_risk_employee.command() +@high_risk_employee.command( + help=f"{DEPRECATION_TEXT}\n\nDisassociates risk tags from a user." +) @username_arg @risk_tag_option @sdk_options() def remove_risk_tags(state, username, risk_tag): - """Disassociates risk tags from a user.""" + deprecation_warning(DEPRECATION_TEXT) _remove_risk_tags(state.sdk, username, risk_tag) -@high_risk_employee.group(cls=OrderedGroup) +@high_risk_employee.group( + cls=OrderedGroup, + help=f"{DEPRECATION_TEXT}\n\nTools for executing high risk employee actions in bulk.", +) @sdk_options(hidden=True) def bulk(state): - """Tools for executing high risk employee actions in bulk.""" pass @@ -125,12 +142,13 @@ def bulk(state): @bulk.command( name="add", - help="Bulk add users to the high risk employees detection list using a CSV file with " - f"format: {','.join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)}.", + help=f"{DEPRECATION_TEXT}\n\nBulk add users to the high risk employees detection list using a " + f"CSV file with format: {','.join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)}.", ) @read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) @sdk_options() def bulk_add(state, csv_rows): + deprecation_warning(DEPRECATION_TEXT) sdk = state.sdk def handle_row(username, cloud_alias, risk_tag, notes): @@ -145,11 +163,13 @@ def handle_row(username, cloud_alias, risk_tag, notes): @bulk.command( name="remove", - help=f"Bulk remove users from the high risk employees detection list using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.", + help=f"{DEPRECATION_TEXT}\n\nBulk remove users from the high risk employees detection list " + f"using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.", ) @read_csv_arg(headers=REMOVE_EMPLOYEE_HEADERS) @sdk_options() def bulk_remove(state, csv_rows): + deprecation_warning(DEPRECATION_TEXT) sdk = state.sdk def handle_row(username): @@ -164,12 +184,13 @@ def handle_row(username): @bulk.command( name="add-risk-tags", - help=f"Adds risk tags to users in bulk using a CSV file with format: " + help=f"{DEPRECATION_TEXT}\n\nAdds risk tags to users in bulk using a CSV file with format: " f"{','.join(RISK_TAG_CSV_HEADERS)}.", ) @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) @sdk_options() def bulk_add_risk_tags(state, csv_rows): + deprecation_warning(DEPRECATION_TEXT) sdk = state.sdk def handle_row(username, tag): @@ -184,12 +205,13 @@ def handle_row(username, tag): @bulk.command( name="remove-risk-tags", - help=f"Removes risk tags from users in bulk using a CSV file with format: " - f"{','.join(RISK_TAG_CSV_HEADERS)}.", + help=f"{DEPRECATION_TEXT}\n\nRemoves risk tags from users in bulk using a CSV file with " + f"format: {','.join(RISK_TAG_CSV_HEADERS)}.", ) @read_csv_arg(headers=RISK_TAG_CSV_HEADERS) @sdk_options() def bulk_remove_risk_tags(state, csv_rows): + deprecation_warning(DEPRECATION_TEXT) sdk = state.sdk def handle_row(username, tag): diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index f67e43839..5f345c41b 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -3,8 +3,8 @@ import click from pandas import DataFrame from pandas import json_normalize -from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42NotFoundError +from py42.exceptions import Py42UserRiskProfileNotFound from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process @@ -221,6 +221,10 @@ def reactivate(state, username): _bulk_user_alias_headers = ["username", "alias"] +_bulk_user_risk_profile_headers = ["username", "start_date", "end_date", "notes"] + +_bulk_user_activation_headers = ["username"] + @users.command(name="move") @username_option("The username of the user to move.", required=True) @@ -252,13 +256,73 @@ def remove_alias(state, username, alias): _remove_cloud_alias(state.sdk, username, alias) +@users.command() +@click.argument("username") +@click.argument( + "date", type=click.DateTime(formats=["%Y-%m-%d"]), required=False, metavar="DATE" +) +@click.option("--clear", is_flag=True, help="Clears the current `start_date` value.") +@sdk_options() +def update_start_date(state, username, date, clear): + """Sets the `start_date` on a User's risk profile (useful for users on the New Hire Watchlist). + Date format: %Y-%m-%d""" + if not date and not clear: + raise Code42CLIError("Must supply DATE argument if --clear is not used.") + if clear: + date = "" + user_id = _get_user(state.sdk, username)["userId"] + state.sdk.userriskprofile.update(user_id, start_date=date) + + +@users.command() +@click.argument("username") +@click.argument("date", type=click.DateTime(formats=["%Y-%m-%d"]), required=False) +@click.option("--clear", is_flag=True, help="Clears the current `end_date` value.") +@sdk_options() +def update_departure_date(state, username, date, clear): + """Sets the `end_date` on a User's risk profile (useful for users on the Departing Watchlist). Date format: %Y-%m-%d""" + if not date and not clear: + raise Code42CLIError("Must supply DATE argument if --clear is not used.") + if clear: + date = "" + user_id = _get_user(state.sdk, username)["userId"] + state.sdk.userriskprofile.update(user_id, end_date=date) + + +@users.command() +@click.argument("username") +@click.argument("note", required=False) +@click.option("--clear", is_flag=True, help="Clears the current `notes` value.") +@click.option( + "--append", + is_flag=True, + help="Appends provided note to existing note text as a new line.", +) +@sdk_options() +def update_risk_profile_notes(state, username, note, clear, append): + """Sets the `notes` value of a User's risk profile. + + WARNING: Overwrites any existing note value.""" + if not note and not clear: + raise Code42CLIError("Must supply NOTE argument if --clear is not used.") + user = _get_user(state.sdk, username) + user_id = user["userId"] + if append and user["notes"]: + note = user["notes"] + f"\n\n{note}" + if clear: + note = "" + state.sdk.userriskprofile.update(user_id, notes=note) + + @users.command() @click.argument("username") @sdk_options() def list_aliases(state, username): - """List the cloud aliases for a given user. Each user has a default cloud alias of their Code42 username with up to one additional alias.""" + """List the cloud aliases for a given user. + + Each user has a default cloud alias of their Code42 username with up to one additional alias.""" user = _get_user(state.sdk, username) - aliases = user["cloudUsernames"] + aliases = user["cloudAliases"] if aliases: click.echo(aliases) else: @@ -336,6 +400,7 @@ def bulk(state): "move": _bulk_user_move_headers, "add-alias": _bulk_user_alias_headers, "remove-alias": _bulk_user_alias_headers, + "update-risk-profile": _bulk_user_risk_profile_headers, }, help_message="Generate the CSV template needed for bulk user commands.", ) @@ -422,9 +487,6 @@ def handle_row(**row): formatter.echo_formatted_list(result_rows) -_bulk_user_activation_headers = ["username"] - - @bulk.command( name="deactivate", help=f"Deactivate a list of users from the provided CSV in format: {','.join(_bulk_user_activation_headers)}", @@ -663,6 +725,50 @@ def handle_row(**row): formatter.echo_formatted_list(result_rows) +@bulk.command( + name="update-risk-profile", + help=f"Update user risk profile data from the provided CSV in format: {','.join(_bulk_user_risk_profile_headers)}" + "\n\nTo clear a value, set column item to the string: 'null'.", +) +@format_option +@read_csv_arg(headers=_bulk_user_risk_profile_headers) +@click.option( + "--append-notes", + is_flag=True, + help="Append provided note value to already existing note on a new line. Defaults to overwrite.", +) +@sdk_options() +def bulk_update_risk_profile(state, csv_rows, format, append_notes): + """Bulk update User Risk Profile data.""" + sdk = state.sdk + + success_header = "updated_user" + formatter = OutputFormatter( + format, {key: key for key in [*csv_rows[0].keys(), success_header]} + ) + stats = create_worker_stats(len(csv_rows)) + + def handle_row(**row): + try: + updated_user = _update_userriskprofile( + sdk, append_notes=append_notes, **row + ) + row[success_header] = updated_user + except Exception as err: + row[success_header] = f"Error: {err}" + stats.increment_total_errors() + return row + + result_rows = run_bulk_process( + handle_row, + csv_rows, + progress_label="Updating user risk profile data:", + stats=stats, + raise_global_error=False, + ) + formatter.echo_formatted_list(result_rows) + + def _add_user_role(sdk, username, role_name): user_id = _get_legacy_user_id(sdk, username) _get_role_id(sdk, role_name) # function provides role name validation @@ -793,18 +899,45 @@ def _reactivate_user(sdk, username): def _get_user(sdk, username): - # use when retrieving the user information from the detectionlists module + # use when retrieving the user risk profile information try: - return sdk.detectionlists.get_user(username).data - except Py42BadRequestError: + return sdk.userriskprofile.get_by_username(username) + except Py42UserRiskProfileNotFound: raise UserDoesNotExistError(username) def _add_cloud_alias(sdk, username, alias): user = _get_user(sdk, username) - sdk.detectionlists.add_user_cloud_alias(user["userId"], alias) + sdk.userriskprofile.add_cloud_aliases(user["userId"], alias) def _remove_cloud_alias(sdk, username, alias): user = _get_user(sdk, username) - sdk.detectionlists.remove_user_cloud_alias(user["userId"], alias) + sdk.userriskprofile.delete_cloud_aliases(user["userId"], alias) + + +def _update_userriskprofile( + sdk, append_notes=False, username=None, start_date=None, end_date=None, notes=None +): + user = _get_user(sdk, username) + user_id = user["userId"] + if append_notes and notes != "null": + notes = user["notes"] + f"\n\n{notes}" + + # py42 interprets empty string as "clear this value" for kwarg values. Since empty CSV columns + # get parsed as "" we want to have user provide explicit 'null' string to indicate desire to + # clear instead of just not update value + start_date = ( + None if start_date == "" else ("" if start_date == "null" else start_date) + ) + end_date = None if end_date == "" else ("" if end_date == "null" else end_date) + notes = None if notes == "" else ("" if notes == "null" else notes) + + updated_user = sdk.userriskprofile.update( + user_id, start_date=start_date, end_date=end_date, notes=notes + ) + return { + k: v + for k, v in updated_user.data.items() + if k in ["username", "userId", "startDate", "endDate", "notes"] + } diff --git a/src/code42cli/cmds/watchlists.py b/src/code42cli/cmds/watchlists.py new file mode 100644 index 000000000..0873fee15 --- /dev/null +++ b/src/code42cli/cmds/watchlists.py @@ -0,0 +1,262 @@ +import csv + +import click +from pandas import DataFrame +from py42.constants import WatchlistType +from py42.exceptions import Py42NotFoundError +from py42.exceptions import Py42WatchlistNotFound + +from code42cli.bulk import generate_template_cmd_factory +from code42cli.bulk import run_bulk_process +from code42cli.click_ext.groups import OrderedGroup +from code42cli.click_ext.options import incompatible_with +from code42cli.click_ext.types import AutoDecodedFile +from code42cli.errors import Code42CLIError +from code42cli.options import format_option +from code42cli.options import sdk_options +from code42cli.output_formats import DataFrameOutputFormatter + + +@click.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def watchlists(state): + """Manage watchlist user memberships.""" + pass + + +@watchlists.command("list") +@format_option +@sdk_options() +def _list(state, format): + """List all watchlists.""" + pages = state.sdk.watchlists.get_all() + dfs = (DataFrame(page["watchlists"]) for page in pages) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframes(dfs) + + +@watchlists.command() +@click.option( + "--watchlist-id", + help="ID of the watchlist.", +) +@click.option( + "--watchlist-type", + type=click.Choice(WatchlistType.choices()), + help="Type of watchlist to list.", + cls=incompatible_with("watchlist_id"), +) +@click.option( + "--only-included-users", + help="Restrict results to users explicitly added to watchlist via API or Console. " + "Users added implicitly via group membership or other dynamic rule will not be listed.", + is_flag=True, +) +@format_option +@sdk_options() +def list_members(state, watchlist_type, watchlist_id, only_included_users, format): + """List all members on a given watchlist.""" + if not watchlist_id and not watchlist_type: + raise click.ClickException("--watchlist-id OR --watchlist-type is required.") + if watchlist_type: + watchlist_id = state.sdk.watchlists._watchlists_service.watchlist_type_id_map[ + watchlist_type + ] + if only_included_users: + pages = state.sdk.watchlists.get_all_included_users(watchlist_id) + dfs = (DataFrame(page["includedUsers"]) for page in pages) + else: + pages = state.sdk.watchlists.get_all_watchlist_members(watchlist_id) + dfs = (DataFrame(page["watchlistMembers"]) for page in pages) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframes(dfs) + + +@watchlists.command() +@click.option( + "--watchlist-id", + help="ID of the watchlist.", +) +@click.option( + "--watchlist-type", + type=click.Choice(WatchlistType.choices()), + help="Type of watchlist to add user to.", + cls=incompatible_with("watchlist_id"), +) +@click.argument("user", metavar="[USER_ID|USERNAME]") +@sdk_options() +def add(state, watchlist_id, watchlist_type, user): + """Add a user to a watchlist.""" + if not watchlist_id and not watchlist_type: + raise click.ClickException("--watchlist-id OR --watchlist-type is required.") + try: + user = int(user) + except ValueError: + # assume username if `user` is not an int + user = state.sdk.userriskprofile.get_by_username(user)["userId"] + try: + if watchlist_id: + state.sdk.watchlists.add_included_users_by_watchlist_id(user, watchlist_id) + elif watchlist_type: + state.sdk.watchlists.add_included_users_by_watchlist_type( + user, watchlist_type + ) + except Py42WatchlistNotFound: + raise + except Py42NotFoundError: + raise Code42CLIError(f"User ID {user} not found.") + + +@watchlists.command() +@click.option("--watchlist-id", help="ID of the watchlist.") +@click.option( + "--watchlist-type", + type=click.Choice(WatchlistType.choices()), + help="Type of watchlist to remove user from.", + cls=incompatible_with("watchlist_id"), +) +@click.argument("user", metavar="[USER_ID|USERNAME]") +@sdk_options() +def remove(state, watchlist_id, watchlist_type, user): + """Remove a user from a watchlist.""" + if not watchlist_id and not watchlist_type: + raise click.ClickException("--watchlist-id OR --watchlist-type is required.") + try: + user = int(user) + except ValueError: + # assume username if `user` is not an int + user = state.sdk.userriskprofile.get_by_username(user)["userId"] + try: + if watchlist_id: + state.sdk.watchlists.remove_included_users_by_watchlist_id( + user, watchlist_id + ) + elif watchlist_type: + state.sdk.watchlists.remove_included_users_by_watchlist_type( + user, watchlist_type + ) + except Py42WatchlistNotFound: + raise + except Py42NotFoundError: + raise Code42CLIError(f"User ID {user} not found.") + + +@watchlists.group(cls=OrderedGroup) +@sdk_options(hidden=True) +def bulk(state): + """Tools for executing bulk watchlist actions.""" + pass + + +watchlists_generate_template = generate_template_cmd_factory( + group_name="watchlists", + commands_dict={ + "add": ["watchlist_id", "watchlist_type", "user_id", "username"], + "remove": ["watchlist_id", "watchlist_type", "user_id", "username"], + }, +) +bulk.add_command(watchlists_generate_template) + + +@bulk.command( + name="add", + help="Bulk add users to watchlists using a CSV file. Requires either a `watchlist_id` or " + "`watchlist_type` column header to identify the watchlist, and either a `user_id` or " + "`username` column header to identify the user to add.", +) +@click.argument( + "csv_rows", + metavar="CSV_FILE", + type=AutoDecodedFile("r"), + callback=lambda ctx, param, arg: csv.DictReader(arg), +) +@sdk_options() +def bulk_add(state, csv_rows): + headers = csv_rows.fieldnames + if "user_id" not in headers and "username" not in headers: + raise Code42CLIError( + "CSV requires either a `username` or `user_id` " + "column to identify which users to add to watchlist." + ) + if "watchlist_id" not in headers and "watchlist_type" not in headers: + raise Code42CLIError( + "CSV requires either a `watchlist_id` or `watchlist_type` " + "column to identify which watchlist to add user to." + ) + + sdk = state.sdk + + def handle_row(watchlist_id=None, watchlist_type=None, user_id=None, username=None): + if username and not user_id: + user_id = sdk.userriskprofile.get_by_username(username)["userId"] + if watchlist_id: + sdk.watchlists.add_included_users_by_watchlist_id(user_id, watchlist_id) + elif watchlist_type: + choices = WatchlistType.choices() + if watchlist_type not in choices: + raise Code42CLIError( + f"Provided watchlist_type `{watchlist_type}` for username={username}, " + f"user_id={user_id} row is invalid. Must be one of: {','.join(choices)}" + ) + sdk.watchlists.add_included_users_by_watchlist_type(user_id, watchlist_type) + else: + raise Code42CLIError( + f"Row for username={username}, user_id={user_id} " + "missing value for `watchlist_id` or `watchlist_type` columns." + ) + + run_bulk_process( + handle_row, + list(csv_rows), + progress_label="Adding users to Watchlists:", + ) + + +@bulk.command( + name="remove", + help="Bulk remove users from watchlists using a CSV file. Requires either a `watchlist_id` or " + "`watchlist_type` column header to identify the watchlist, and either a `user_id` or " + "`username` header to identify the user to remove.", +) +@click.argument( + "csv_rows", + metavar="CSV_FILE", + type=AutoDecodedFile("r"), + callback=lambda ctx, param, arg: csv.DictReader(arg), +) +@sdk_options() +def bulk_remove(state, csv_rows): + headers = csv_rows.fieldnames + if "user_id" not in headers and "username" not in headers: + raise Code42CLIError( + "CSV requires either a `username` or `user_id` " + "column to identify which users to remove from watchlist." + ) + if "watchlist_id" not in headers and "watchlist_type" not in headers: + raise Code42CLIError( + "CSV requires either a `watchlist_id` or `watchlist_type` " + "column to identify which watchlist to remove user from." + ) + + sdk = state.sdk + + def handle_row(watchlist_id=None, watchlist_type=None, user_id=None, username=None): + if username and not user_id: + user_id = sdk.userriskprofile.get_by_username(username)["userId"] + if watchlist_id: + sdk.watchlists.remove_included_users_by_watchlist_id(user_id, watchlist_id) + elif watchlist_type: + sdk.watchlists.remove_included_users_by_watchlist_type( + user_id, watchlist_type + ) + else: + raise Code42CLIError( + f"Row for username={username}, user_id={user_id} " + "missing value for `watchlist_id` or `watchlist_type` columns." + ) + + run_bulk_process( + handle_row, + list(csv_rows), + progress_label="Adding users to Watchlists:", + ) diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 897b96a0a..5427c6530 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -2,6 +2,7 @@ import signal import site import sys +import warnings import click from click_plugins import with_plugins @@ -24,8 +25,11 @@ from code42cli.cmds.shell import shell from code42cli.cmds.trustedactivities import trusted_activities from code42cli.cmds.users import users +from code42cli.cmds.watchlists import watchlists from code42cli.options import sdk_options +warnings.simplefilter("ignore", DeprecationWarning) + # Handle KeyboardInterrupts by just exiting instead of printing out a stack def exit_on_interrupt(signal, frame): @@ -93,3 +97,4 @@ def cli(state, python, script_dir): cli.add_command(shell) cli.add_command(users) cli.add_command(trusted_activities) +cli.add_command(watchlists) diff --git a/src/code42cli/util.py b/src/code42cli/util.py index fd961d9d9..1ef2c0beb 100644 --- a/src/code42cli/util.py +++ b/src/code42cli/util.py @@ -198,3 +198,7 @@ def parse_timestamp(date_str): ts = date_str[:-1] date = dateutil.parser.parse(ts).replace(tzinfo=timezone.utc) return date.timestamp() + + +def deprecation_warning(text): + echo(style(text, fg="red"), err=True) diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index 418dd76e4..a9006f6b2 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -1,8 +1,8 @@ +import datetime import json import pytest from py42.exceptions import Py42ActiveLegalHoldError -from py42.exceptions import Py42BadRequestError from py42.exceptions import Py42CloudAliasCharacterLimitExceededError from py42.exceptions import Py42CloudAliasLimitExceededError from py42.exceptions import Py42InvalidEmailError @@ -10,6 +10,7 @@ from py42.exceptions import Py42InvalidUsernameError from py42.exceptions import Py42NotFoundError from py42.exceptions import Py42OrgNotFoundError +from py42.exceptions import Py42UserRiskProfileNotFound from tests.conftest import create_mock_http_error from tests.conftest import create_mock_response @@ -49,7 +50,7 @@ "userName": "Sample.User1@samplecase.com", "displayName": "Sample User1", "notes": "This is an example of notes about Sample User1.", - "cloudUsernames": ["Sample.User1@samplecase.com", "Sample.User1@gmail.com"], + "cloudAliases": ["Sample.User1@samplecase.com", "Sample.User1@gmail.com"], "managerUid": 12345, "managerUsername": "manager.user1@samplecase.com", "managerDisplayName": "Manager Name", @@ -100,7 +101,7 @@ TEST_EMPTY_MATTERS_RESPONSE = {"legalHolds": []} TEST_EMPTY_USERS_RESPONSE = {"users": []} TEST_USERNAME = TEST_USERS_RESPONSE["users"][0]["username"] -TEST_ALIAS = TEST_USER_RESPONSE["cloudUsernames"][0] +TEST_ALIAS = TEST_USER_RESPONSE["cloudAliases"][0] TEST_USER_ID = TEST_USERS_RESPONSE["users"][0]["userId"] TEST_USER_UID = TEST_USER_RESPONSE["userId"] TEST_ROLE_NAME = TEST_ROLE_RETURN_DATA["data"][0]["roleName"] @@ -159,7 +160,7 @@ def get_user_response(mocker): @pytest.fixture def get_user_failure(mocker): - return Py42BadRequestError( + return Py42UserRiskProfileNotFound( create_mock_http_error(mocker, data=None, status=400), "Failure in HTTP call 400 Client Error: Bad Request for url: https://ecm-east.us.code42.com/svc/api/v2/user/getbyusername.", ) @@ -212,13 +213,13 @@ def get_user_id_success(cli_state, get_users_response): @pytest.fixture def get_user_uid_success(cli_state, get_user_response): - """detectionlists.get_user returns a single user""" - cli_state.sdk.detectionlists.get_user.return_value = get_user_response + """userriskprofile.get_by_username returns a single user""" + cli_state.sdk.userriskprofile.get_by_username.return_value = get_user_response @pytest.fixture def get_user_uid_failure(cli_state, get_user_failure): - cli_state.sdk.detectionlists.get_user.side_effect = get_user_failure + cli_state.sdk.userriskprofile.get_by_username.side_effect = get_user_failure @pytest.fixture @@ -296,23 +297,16 @@ def change_org_success(cli_state, change_org_response): cli_state.sdk.users.change_org_assignment.return_value = change_org_response -@pytest.fixture -def add_alias_success(mocker, cli_state): - cli_state.sdk.detectionlists.add_user_cloud_alias.return_value = ( - create_mock_response(mocker) - ) - - @pytest.fixture def add_alias_limit_failure(mocker, cli_state): - cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = ( + cli_state.sdk.userriskprofile.add_cloud_aliases.side_effect = ( Py42CloudAliasLimitExceededError(create_mock_http_error(mocker)) ) @pytest.fixture def remove_alias_success(mocker, cli_state): - cli_state.sdk.detectionlists.remove_user_cloud_alias.return_value = ( + cli_state.sdk.userriskprofile.delete_cloud_aliases.return_value = ( create_mock_response(mocker) ) @@ -1450,14 +1444,16 @@ def test_list_aliases_calls_get_user_with_expected_parameters(runner, cli_state) username = "alias@example" command = ["users", "list-aliases", username] runner.invoke(cli, command, obj=cli_state) - cli_state.sdk.detectionlists.get_user.assert_called_once_with("alias@example") + cli_state.sdk.userriskprofile.get_by_username.assert_called_once_with( + "alias@example" + ) def test_list_aliases_prints_no_aliases_found_when_empty_list( runner, cli_state, mocker ): - cli_state.sdk.detectionlists.get_user.return_value = create_mock_response( - mocker, data={"cloudUsernames": []} + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"cloudAliases": []} ) username = "alias@example" command = ["users", "list-aliases", username] @@ -1488,11 +1484,11 @@ def test_list_aliases_raises_error_when_user_does_not_exist( def test_add_cloud_alias_calls_add_cloud_alias_with_correct_parameters( - runner, cli_state, get_user_uid_success, add_alias_success + runner, cli_state, get_user_uid_success ): command = ["users", "add-alias", "test@example.com", "alias@example.com"] runner.invoke(cli, command, obj=cli_state) - cli_state.sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( + cli_state.sdk.userriskprofile.add_cloud_aliases.assert_called_once_with( TEST_USER_UID, "alias@example.com" ) @@ -1517,7 +1513,7 @@ def test_add_alias_raises_error_when_alias_is_too_long(runner, cli_state): "fake@notreal.com", "alias-is-too-long-its-very-long-for-real-more-than-50-characters@example.com", ] - cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = ( + cli_state.sdk.userriskprofile.add_cloud_aliases.side_effect = ( Py42CloudAliasCharacterLimitExceededError ) result = runner.invoke(cli, command, obj=cli_state) @@ -1547,7 +1543,7 @@ def test_remove_cloud_alias_calls_remove_cloud_alias_with_correct_parameters( ): command = ["users", "remove-alias", "test@example.com", "alias@example.com"] runner.invoke(cli, command, obj=cli_state) - cli_state.sdk.detectionlists.remove_user_cloud_alias.assert_called_once_with( + cli_state.sdk.userriskprofile.delete_cloud_aliases.assert_called_once_with( TEST_USER_UID, "alias@example.com" ) @@ -1609,7 +1605,7 @@ def _get(username, *args, **kwargs): raise Exception("TEST") return get_user_response - cli_state.sdk.detectionlists.get_user.side_effect = _get + cli_state.sdk.userriskprofile.get_by_username.side_effect = _get bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_add_alias.csv", "w") as csv: @@ -1669,7 +1665,7 @@ def _get(username, *args, **kwargs): raise Exception("TEST") return get_user_response - cli_state.sdk.detectionlists.get_user.side_effect = _get + cli_state.sdk.userriskprofile.get_by_username.side_effect = _get bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") with runner.isolated_filesystem(): with open("test_remove_alias.csv", "w") as csv: @@ -1683,3 +1679,179 @@ def _get(username, *args, **kwargs): handler(username="test@example.com", alias=TEST_ALIAS) handler(username="not.test@example.com", alias=TEST_ALIAS) assert worker_stats.increment_total_errors.call_count == 1 + + +def test_update_start_date_without_date_arg_or_clear_option_raises_cli_error( + runner, cli_state +): + res = runner.invoke( + cli, ["users", "update-start-date", "test@example.com"], obj=cli_state + ) + assert res.exit_code == 1 + assert "Must supply DATE argument if --clear is not used." in res.output + + +def test_update_start_date_with_date_makes_expected_call(mocker, runner, cli_state): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234} + ) + res = runner.invoke( + cli, + ["users", "update-start-date", "test@example.com", "2020-10-10"], + obj=cli_state, + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with( + 1234, start_date=datetime.datetime(2020, 10, 10) + ) + + +def test_update_start_date_with_clear_option_clears_date(mocker, runner, cli_state): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234} + ) + res = runner.invoke( + cli, + ["users", "update-start-date", "test@example.com", "--clear"], + obj=cli_state, + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with(1234, start_date="") + + +def test_update_departure_date_without_date_arg_or_clear_option_raises_cli_error( + runner, cli_state +): + res = runner.invoke( + cli, ["users", "update-departure-date", "test@example.com"], obj=cli_state + ) + assert res.exit_code == 1 + assert "Must supply DATE argument if --clear is not used." in res.output + + +def test_update_departure_date_with_clear_option_clears_date(mocker, runner, cli_state): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234} + ) + res = runner.invoke( + cli, + ["users", "update-departure-date", "test@example.com", "--clear"], + obj=cli_state, + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with(1234, end_date="") + + +def test_update_departure_date_with_date_makes_expected_call(mocker, runner, cli_state): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234} + ) + res = runner.invoke( + cli, + ["users", "update-departure-date", "test@example.com", "2020-10-10"], + obj=cli_state, + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with( + 1234, end_date=datetime.datetime(2020, 10, 10) + ) + + +def test_update_notes_without_note_arg_or_clear_option_raises_cli_error( + runner, cli_state +): + res = runner.invoke( + cli, ["users", "update-risk-profile-notes", "test@example.com"], obj=cli_state + ) + assert res.exit_code == 1 + assert "Must supply NOTE argument if --clear is not used." in res.output + + +def test_update_notes_with_clear_option_clears_date(mocker, runner, cli_state): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234} + ) + res = runner.invoke( + cli, + ["users", "update-risk-profile-notes", "test@example.com", "--clear"], + obj=cli_state, + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with(1234, notes="") + + +def test_update_notes_with_note_makes_expected_call(mocker, runner, cli_state): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234} + ) + res = runner.invoke( + cli, + ["users", "update-risk-profile-notes", "test@example.com", "new note"], + obj=cli_state, + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with(1234, notes="new note") + + +def test_update_notes_with_append_option_appends_note_value(mocker, runner, cli_state): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234, "notes": "existing note"} + ) + res = runner.invoke( + cli, + [ + "users", + "update-risk-profile-notes", + "test@example.com", + "--append", + "new note", + ], + obj=cli_state, + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with( + 1234, notes="existing note\n\nnew note" + ) + + +def test_bulk_update_risk_profile_makes_expected_calls(mocker, runner, cli_state): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234} + ) + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write( + "username,start_date,end_date,notes\ntest@example.com,2020-10-10,2022-10-10,new note\n" + ) + res = runner.invoke( + cli, ["users", "bulk", "update-risk-profile", "csv"], obj=cli_state + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with( + 1234, start_date="2020-10-10", end_date="2022-10-10", notes="new note" + ) + + +def test_bulk_update_risk_profile_with_append_note_option_appends_note( + mocker, runner, cli_state +): + cli_state.sdk.userriskprofile.get_by_username.return_value = create_mock_response( + mocker, data={"userId": 1234, "notes": "existing note"} + ) + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write( + "username,start_date,end_date,notes\ntest@example.com,2020-10-10,2022-10-10,new note\n" + ) + res = runner.invoke( + cli, + ["users", "bulk", "update-risk-profile", "--append-notes", "csv"], + obj=cli_state, + ) + assert res.exit_code == 0 + cli_state.sdk.userriskprofile.update.assert_called_once_with( + 1234, + start_date="2020-10-10", + end_date="2022-10-10", + notes="existing note\n\nnew note", + ) diff --git a/tests/cmds/test_watchlists.py b/tests/cmds/test_watchlists.py new file mode 100644 index 000000000..b26607b4f --- /dev/null +++ b/tests/cmds/test_watchlists.py @@ -0,0 +1,666 @@ +import pytest +from py42.exceptions import Py42NotFoundError +from py42.exceptions import Py42UserRiskProfileNotFound +from py42.exceptions import Py42WatchlistNotFound + +from .conftest import create_mock_response +from code42cli.main import cli + +WATCHLISTS_RESPONSE = { + "watchlists": [ + { + "listType": "DEPARTING_EMPLOYEE", + "watchlistId": "departing-id", + "tenantId": "tenant-123", + "stats": {"includedUsersCount": 50}, + }, + { + "listType": "HIGH_IMPACT_EMPLOYEE", + "watchlistId": "high-impact-id", + "tenantId": "tenant-123", + "stats": {"includedUsersCount": 3}, + }, + { + "listType": "POOR_SECURITY_PRACTICES", + "watchlistId": "poor-security-id", + "tenantId": "tenant-123", + "stats": {"includedUsersCount": 2}, + }, + { + "listType": "FLIGHT_RISK", + "watchlistId": "flight-risk-id", + "tenantId": "tenant-123", + "stats": {"includedUsersCount": 2}, + }, + { + "listType": "SUSPICIOUS_SYSTEM_ACTIVITY", + "watchlistId": "suspicious-id", + "tenantId": "tenant-123", + "stats": {"includedUsersCount": 2}, + }, + { + "listType": "CONTRACT_EMPLOYEE", + "watchlistId": "contract-id", + "tenantId": "tenant-123", + "stats": {"includedUsersCount": 2}, + }, + { + "listType": "NEW_EMPLOYEE", + "watchlistId": "new-employee-id", + "tenantId": "tenant-123", + "stats": {"includedUsersCount": 1}, + }, + { + "listType": "ELEVATED_ACCESS_PRIVILEGES", + "watchlistId": "elevated-id", + "tenantId": "tenant-123", + "stats": {}, + }, + { + "listType": "PERFORMANCE_CONCERNS", + "watchlistId": "performance-id", + "tenantId": "tenant-123", + "stats": {}, + }, + ], + "totalCount": 9, +} + +WATCHLISTS_MEMBERS_RESPONSE = { + "watchlistMembers": [ + { + "userId": "1234", + "username": "one@example.com", + "addedTime": "2022-04-10T23:05:48.096964", + }, + { + "userId": "2345", + "username": "two@example.com", + "addedTime": "2022-02-26T18:52:36.805807", + }, + { + "userId": "3456", + "username": "three@example.com", + "addedTime": "2022-02-26T18:52:36.805807", + }, + ], + "totalCount": 3, +} + +WATCHLISTS_INCLUDED_USERS_RESPONSE = { + "includedUsers": [ + { + "userId": "1234", + "username": "one@example.com", + "addedTime": "2022-04-10T23:05:48.096964", + }, + { + "userId": "2345", + "username": "two@example.com", + "addedTime": "2022-02-26T18:52:36.805807", + }, + { + "userId": "3456", + "username": "three@example.com", + "addedTime": "2022-02-26T18:52:36.805807", + }, + ], + "totalCount": 3, +} + + +@pytest.fixture() +def mock_watchlists_response(mocker): + return create_mock_response(mocker, data=WATCHLISTS_RESPONSE) + + +@pytest.fixture() +def mock_included_users_response(mocker): + return create_mock_response(mocker, data=WATCHLISTS_INCLUDED_USERS_RESPONSE) + + +@pytest.fixture() +def mock_members_response(mocker): + return create_mock_response(mocker, data=WATCHLISTS_MEMBERS_RESPONSE) + + +class TestWatchlistsListCmd: + def test_table_output_contains_expected_properties( + self, runner, cli_state, mock_watchlists_response + ): + cli_state.sdk.watchlists.get_all.return_value = iter([mock_watchlists_response]) + res = runner.invoke(cli, ["watchlists", "list"], obj=cli_state) + assert "listType" in res.output + assert "watchlistId" in res.output + assert "tenantId" in res.output + assert "stats" in res.output + assert "DEPARTING_EMPLOYEE" in res.output + assert "includedUsersCount" in res.output + assert "tenant-123" in res.output + + def test_json_output_contains_expected_properties( + self, runner, cli_state, mock_watchlists_response + ): + cli_state.sdk.watchlists.get_all.return_value = iter([mock_watchlists_response]) + res = runner.invoke(cli, ["watchlists", "list", "-f", "JSON"], obj=cli_state) + assert "listType" in res.output + assert "watchlistId" in res.output + assert "tenantId" in res.output + assert "stats" in res.output + assert "DEPARTING_EMPLOYEE" in res.output + assert "includedUsersCount" in res.output + assert "tenant-123" in res.output + + def test_csv_ouput_contains_expected_properties( + self, runner, cli_state, mock_watchlists_response + ): + cli_state.sdk.watchlists.get_all.return_value = iter([mock_watchlists_response]) + res = runner.invoke(cli, ["watchlists", "list", "-f", "CSV"], obj=cli_state) + assert "listType" in res.output + assert "watchlistId" in res.output + assert "tenantId" in res.output + assert "stats" in res.output + assert "DEPARTING_EMPLOYEE" in res.output + assert "includedUsersCount" in res.output + assert "tenant-123" in res.output + + +class TestWatchlistsListMembersCmd: + def test_table_output_contains_expected_properties( + self, runner, cli_state, mock_members_response, mock_included_users_response + ): + # all members: + cli_state.sdk.watchlists.get_all_watchlist_members.return_value = iter( + [mock_members_response] + ) + res = runner.invoke( + cli, + ["watchlists", "list-members", "--watchlist-id", "test-id"], + obj=cli_state, + ) + assert "userId" in res.output + assert "username" in res.output + assert "addedTime" in res.output + assert "1234" in res.output + assert "2345" in res.output + assert "3456" in res.output + assert "one@example.com" in res.output + assert "two@example.com" in res.output + assert "three@example.com" in res.output + assert "2022-04-10T23:05:48.096964" in res.output + + # only included users: + cli_state.sdk.watchlists.get_all_included_users.return_value = iter( + [mock_included_users_response] + ) + res = runner.invoke( + cli, + [ + "watchlists", + "list-members", + "--watchlist-id", + "test-id", + "--only-included-users", + ], + obj=cli_state, + ) + assert "userId" in res.output + assert "username" in res.output + assert "addedTime" in res.output + assert "1234" in res.output + assert "2345" in res.output + assert "3456" in res.output + assert "one@example.com" in res.output + assert "two@example.com" in res.output + assert "three@example.com" in res.output + assert "2022-04-10T23:05:48.096964" in res.output + + def test_json_output_contains_expected_properties( + self, runner, cli_state, mock_members_response, mock_included_users_response + ): + # all members: + cli_state.sdk.watchlists.get_all_watchlist_members.return_value = iter( + [mock_members_response] + ) + res = runner.invoke( + cli, + ["watchlists", "list-members", "--watchlist-id", "test-id", "-f", "JSON"], + obj=cli_state, + ) + assert "userId" in res.output + assert "username" in res.output + assert "addedTime" in res.output + assert "1234" in res.output + assert "2345" in res.output + assert "3456" in res.output + assert "one@example.com" in res.output + assert "two@example.com" in res.output + assert "three@example.com" in res.output + assert "2022-04-10T23:05:48.096964" in res.output + + # only included users: + cli_state.sdk.watchlists.get_all_included_users.return_value = iter( + [mock_included_users_response] + ) + res = runner.invoke( + cli, + [ + "watchlists", + "list-members", + "--watchlist-id", + "test-id", + "-f", + "JSON", + "--only-included-users", + ], + obj=cli_state, + ) + assert "userId" in res.output + assert "username" in res.output + assert "addedTime" in res.output + assert "1234" in res.output + assert "2345" in res.output + assert "3456" in res.output + assert "one@example.com" in res.output + assert "two@example.com" in res.output + assert "three@example.com" in res.output + assert "2022-04-10T23:05:48.096964" in res.output + + def test_csv_output_contains_expected_properties( + self, runner, cli_state, mock_members_response, mock_included_users_response + ): + # all members: + cli_state.sdk.watchlists.get_all_watchlist_members.return_value = iter( + [mock_members_response] + ) + res = runner.invoke( + cli, + ["watchlists", "list-members", "--watchlist-id", "test-id", "-f", "CSV"], + obj=cli_state, + ) + assert "userId" in res.output + assert "username" in res.output + assert "addedTime" in res.output + assert "1234" in res.output + assert "2345" in res.output + assert "3456" in res.output + assert "one@example.com" in res.output + assert "two@example.com" in res.output + assert "three@example.com" in res.output + assert "2022-04-10T23:05:48.096964" in res.output + + # only included users: + cli_state.sdk.watchlists.get_all_included_users.return_value = iter( + [mock_included_users_response] + ) + res = runner.invoke( + cli, + [ + "watchlists", + "list-members", + "--watchlist-id", + "test-id", + "-f", + "CSV", + "--only-included-users", + ], + obj=cli_state, + ) + assert "userId" in res.output + assert "username" in res.output + assert "addedTime" in res.output + assert "1234" in res.output + assert "2345" in res.output + assert "3456" in res.output + assert "one@example.com" in res.output + assert "two@example.com" in res.output + assert "three@example.com" in res.output + assert "2022-04-10T23:05:48.096964" in res.output + + def test_invalid_watchlist_type_raises_cli_error(self, runner, cli_state): + res = runner.invoke( + cli, + ["watchlists", "list-members", "--watchlist-type", "INVALID"], + obj=cli_state, + ) + assert res.exit_code == 2 + assert "Invalid value for '--watchlist-type'" in res.output + + def test_missing_watchlist_identifying_option_raises_cli_error( + self, runner, cli_state + ): + res = runner.invoke( + cli, + ["watchlists", "list-members"], + obj=cli_state, + ) + assert res.exit_code == 1 + assert "Error: --watchlist-id OR --watchlist-type is required" in res.output + + +class TestWatchlistsAddCmd: + def test_missing_watchlist_identifying_option_raises_cli_error( + self, runner, cli_state + ): + res = runner.invoke(cli, ["watchlists", "add", "1234"], obj=cli_state) + assert res.exit_code == 1 + assert "Error: --watchlist-id OR --watchlist-type is required" in res.output + + def test_invalid_watchlist_type_raises_cli_error(self, runner, cli_state): + res = runner.invoke( + cli, + ["watchlists", "add", "--watchlist-type", "INVALID", "1234"], + obj=cli_state, + ) + assert res.exit_code == 2 + assert "Invalid value for '--watchlist-type'" in res.output + + def test_non_int_user_arg_calls_get_by_username_and_uses_user_id( + self, mocker, runner, cli_state + ): + mock_user_response = create_mock_response(mocker, data={"userId": 1234}) + cli_state.sdk.userriskprofile.get_by_username.return_value = mock_user_response + runner.invoke( + cli, + [ + "watchlists", + "add", + "--watchlist-type", + "DEPARTING_EMPLOYEE", + "test@example.com", + ], + obj=cli_state, + ) + cli_state.sdk.userriskprofile.get_by_username.assert_called_once_with( + "test@example.com" + ) + cli_state.sdk.watchlists.add_included_users_by_watchlist_type.assert_called_once_with( + 1234, "DEPARTING_EMPLOYEE" + ) + + def test_invalid_username_raises_not_found_cli_error( + self, custom_error, runner, cli_state + ): + username = "test@example.com" + cli_state.sdk.userriskprofile.get_by_username.side_effect = ( + Py42UserRiskProfileNotFound(custom_error, username, identifier="username") + ) + res = runner.invoke( + cli, + ["watchlists", "add", "--watchlist-type", "DEPARTING_EMPLOYEE", username], + obj=cli_state, + ) + assert res.exit_code == 1 + assert ( + "Error: User risk profile for user with the username 'test@example.com' not found." + in res.output + ) + + def test_invalid_user_id_raises_not_found_cli_error( + self, custom_error, runner, cli_state + ): + cli_state.sdk.watchlists.add_included_users_by_watchlist_type.side_effect = ( + Py42NotFoundError(custom_error) + ) + cli_state.sdk.watchlists.add_included_users_by_watchlist_id.side_effect = ( + Py42NotFoundError(custom_error) + ) + res = runner.invoke( + cli, + ["watchlists", "add", "--watchlist-type", "DEPARTING_EMPLOYEE", "1234"], + obj=cli_state, + ) + assert res.exit_code == 1 + assert "Error: User ID 1234 not found." in res.output + + res = runner.invoke( + cli, + ["watchlists", "add", "--watchlist-id", "id", "1234"], + obj=cli_state, + ) + assert res.exit_code == 1 + assert "Error: User ID 1234 not found." in res.output + + def test_invalid_watchlist_id_raises_not_found_cli_error( + self, custom_error, runner, cli_state + ): + invalid_watchlist_id = "INVALID" + cli_state.sdk.watchlists.add_included_users_by_watchlist_id.side_effect = ( + Py42WatchlistNotFound(custom_error, invalid_watchlist_id) + ) + res = runner.invoke( + cli, + ["watchlists", "add", "--watchlist-id", invalid_watchlist_id, "1234"], + obj=cli_state, + ) + assert res.exit_code == 1 + assert "Error: Watchlist ID 'INVALID' not found." in res.output + + +class TestWatchlistsRemoveCmd: + def test_missing_watchlist_identifying_option_raises_cli_error( + self, runner, cli_state + ): + res = runner.invoke(cli, ["watchlists", "remove", "1234"], obj=cli_state) + assert res.exit_code == 1 + assert "Error: --watchlist-id OR --watchlist-type is required" in res.output + + def test_invalid_watchlist_type_raises_cli_error(self, runner, cli_state): + res = runner.invoke( + cli, + ["watchlists", "remove", "--watchlist-type", "INVALID", "1234"], + obj=cli_state, + ) + assert res.exit_code == 2 + assert "Invalid value for '--watchlist-type'" in res.output + + def test_non_int_user_arg_calls_get_by_username_and_uses_user_id( + self, mocker, runner, cli_state + ): + mock_user_response = create_mock_response(mocker, data={"userId": 1234}) + cli_state.sdk.userriskprofile.get_by_username.return_value = mock_user_response + runner.invoke( + cli, + [ + "watchlists", + "remove", + "--watchlist-type", + "DEPARTING_EMPLOYEE", + "test@example.com", + ], + obj=cli_state, + ) + cli_state.sdk.userriskprofile.get_by_username.assert_called_once_with( + "test@example.com" + ) + cli_state.sdk.watchlists.remove_included_users_by_watchlist_type.assert_called_once_with( + 1234, "DEPARTING_EMPLOYEE" + ) + + def test_invalid_username_raises_not_found_cli_error( + self, custom_error, runner, cli_state + ): + username = "test@example.com" + cli_state.sdk.userriskprofile.get_by_username.side_effect = ( + Py42UserRiskProfileNotFound(custom_error, username, identifier="username") + ) + res = runner.invoke( + cli, + [ + "watchlists", + "remove", + "--watchlist-type", + "DEPARTING_EMPLOYEE", + username, + ], + obj=cli_state, + ) + assert res.exit_code == 1 + assert ( + "Error: User risk profile for user with the username 'test@example.com' not found." + in res.output + ) + + def test_invalid_user_id_raises_not_found_cli_error( + self, custom_error, runner, cli_state + ): + cli_state.sdk.watchlists.remove_included_users_by_watchlist_type.side_effect = ( + Py42NotFoundError(custom_error) + ) + cli_state.sdk.watchlists.remove_included_users_by_watchlist_id.side_effect = ( + Py42NotFoundError(custom_error) + ) + res = runner.invoke( + cli, + ["watchlists", "remove", "--watchlist-type", "DEPARTING_EMPLOYEE", "1234"], + obj=cli_state, + ) + assert res.exit_code == 1 + assert "Error: User ID 1234 not found." in res.output + + res = runner.invoke( + cli, + ["watchlists", "remove", "--watchlist-id", "id", "1234"], + obj=cli_state, + ) + assert res.exit_code == 1 + assert "Error: User ID 1234 not found." in res.output + + def test_invalid_watchlist_id_raises_not_found_cli_error( + self, custom_error, runner, cli_state + ): + invalid_watchlist_id = "INVALID" + cli_state.sdk.watchlists.remove_included_users_by_watchlist_id.side_effect = ( + Py42WatchlistNotFound(custom_error, invalid_watchlist_id) + ) + res = runner.invoke( + cli, + ["watchlists", "remove", "--watchlist-id", invalid_watchlist_id, "1234"], + obj=cli_state, + ) + assert res.exit_code == 1 + assert "Error: Watchlist ID 'INVALID' not found." in res.output + + +class TestWatchlistBulkAddCmd: + def test_csv_without_either_username_or_user_id_raises_cli_error( + self, runner, cli_state + ): + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write("watchlist_id,watchlist_type\n") + res = runner.invoke( + cli, ["watchlists", "bulk", "add", "csv"], obj=cli_state + ) + assert res.exit_code == 1 + assert ( + "Error: CSV requires either a `username` or `user_id` column to identify which users to add to watchlist." + in res.output + ) + + def test_csv_without_either_watchlist_type_or_watchlist_id_raises_cli_error( + self, runner, cli_state + ): + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write("username,user_id\n") + res = runner.invoke( + cli, ["watchlists", "bulk", "add", "csv"], obj=cli_state + ) + assert res.exit_code == 1 + assert ( + "Error: CSV requires either a `watchlist_id` or `watchlist_type` column to identify which watchlist to add user to." + in res.output + ) + + def test_handle_row_when_passed_all_headers_uses_user_id_and_watchlist_id( + self, runner, cli_state + ): + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write( + "username,user_id,watchlist_id,watchlist_type\ntest@example.com,1234,abcd,DEPARTING_EMPLOYEE\n" + ) + runner.invoke(cli, ["watchlists", "bulk", "add", "csv"], obj=cli_state) + cli_state.sdk.watchlists.add_included_users_by_watchlist_id.assert_called_once_with( + "1234", "abcd" + ) + + def test_handle_row_when_passed_no_id_headers_uses_username_and_watchlist_type( + self, mocker, runner, cli_state + ): + cli_state.sdk.userriskprofile.get_by_username.return_value = ( + create_mock_response(mocker, data={"userId": 1234}) + ) + + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write( + "username,watchlist_type\ntest@example.com,DEPARTING_EMPLOYEE\n" + ) + runner.invoke(cli, ["watchlists", "bulk", "add", "csv"], obj=cli_state) + cli_state.sdk.watchlists.add_included_users_by_watchlist_type.assert_called_once_with( + 1234, "DEPARTING_EMPLOYEE" + ) + + +class TestWatchlistBulkRemoveCmd: + def test_csv_without_either_username_or_user_id_raises_cli_error( + self, runner, cli_state + ): + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write("watchlist_id,watchlist_type\n") + res = runner.invoke( + cli, ["watchlists", "bulk", "remove", "csv"], obj=cli_state + ) + assert res.exit_code == 1 + assert ( + "Error: CSV requires either a `username` or `user_id` column to identify which users to remove from watchlist." + in res.output + ) + + def test_csv_without_either_watchlist_type_or_watchlist_id_raises_cli_error( + self, runner, cli_state + ): + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write("username,user_id\n") + res = runner.invoke( + cli, ["watchlists", "bulk", "remove", "csv"], obj=cli_state + ) + assert res.exit_code == 1 + assert ( + "Error: CSV requires either a `watchlist_id` or `watchlist_type` column to identify which watchlist to remove user from." + in res.output + ) + + def test_handle_row_when_passed_all_headers_uses_user_id_and_watchlist_id( + self, runner, cli_state + ): + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write( + "username,user_id,watchlist_id,watchlist_type\ntest@example.com,1234,abcd,DEPARTING_EMPLOYEE\n" + ) + runner.invoke(cli, ["watchlists", "bulk", "remove", "csv"], obj=cli_state) + cli_state.sdk.watchlists.remove_included_users_by_watchlist_id.assert_called_once_with( + "1234", "abcd" + ) + + def test_handle_row_when_passed_no_id_headers_uses_username_and_watchlist_type( + self, mocker, runner, cli_state + ): + cli_state.sdk.userriskprofile.get_by_username.return_value = ( + create_mock_response(mocker, data={"userId": 1234}) + ) + + with runner.isolated_filesystem(): + with open("csv", "w") as file: + file.write( + "username,watchlist_type\ntest@example.com,DEPARTING_EMPLOYEE\n" + ) + runner.invoke(cli, ["watchlists", "bulk", "remove", "csv"], obj=cli_state) + cli_state.sdk.watchlists.remove_included_users_by_watchlist_type.assert_called_once_with( + 1234, "DEPARTING_EMPLOYEE" + ) From a02659e11671b9d5858a2906b3fc58e06f266ea8 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 12 May 2022 10:42:46 -0500 Subject: [PATCH 312/349] bump py42 (#368) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c67123ee7..afad965de 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython==7.16.3", "pandas>=1.1.3", - "py42>=1.22.0", + "py42>=1.23.0", ], extras_require={ "dev": [ From 384b7b0b9abf7ae7b2dd3869fbc307515410b541 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 19 May 2022 15:35:13 -0500 Subject: [PATCH 313/349] version 1.14.0 (#369) * bump version & changelog * style miss * add python 3.9 and 3.10 to tox * add python 3.9 and 3.10 to github builds * remove 3.10 for now --- .github/workflows/build.yml | 2 +- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- src/code42cli/cmds/departing_employee.py | 23 ++++++++++++++++++----- tox.ini | 2 +- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5e93ec38..688324d19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.6, 3.7, 3.8] + python: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index a6115ad4b..7b84b58ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The intended audience of this file is for py42 consumers -- as such, changes tha how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.14.0 - 2022-05-18 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 9a34ccc9f..b9f68edb2 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.13.0" +__version__ = "1.14.0" diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py index 50c432163..5a683f45e 100644 --- a/src/code42cli/cmds/departing_employee.py +++ b/src/code42cli/cmds/departing_employee.py @@ -37,13 +37,19 @@ def _get_filter_choices(): ) -@click.group(cls=OrderedGroup, help=f"{DEPRECATION_TEXT}\n\nAdd and remove employees from the Departing Employees detection list.") +@click.group( + cls=OrderedGroup, + help=f"{DEPRECATION_TEXT}\n\nAdd and remove employees from the Departing Employees detection list.", +) @sdk_options(hidden=True) def departing_employee(state): pass -@departing_employee.command("list", help=f"{DEPRECATION_TEXT}\n\nLists the users on the Departing Employees list.") +@departing_employee.command( + "list", + help=f"{DEPRECATION_TEXT}\n\nLists the users on the Departing Employees list.", +) @sdk_options() @format_option @filter_option @@ -57,7 +63,9 @@ def _list(state, format, filter): ) -@departing_employee.command(help=f"{DEPRECATION_TEXT}\n\nAdd a user to the Departing Employees detection list.") +@departing_employee.command( + help=f"{DEPRECATION_TEXT}\n\nAdd a user to the Departing Employees detection list." +) @username_arg @click.option( "--departure-date", @@ -73,7 +81,9 @@ def add(state, username, cloud_alias, departure_date, notes): _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) -@departing_employee.command(help=f"{DEPRECATION_TEXT}\n\nRemove a user from the Departing Employees detection list.") +@departing_employee.command( + help=f"{DEPRECATION_TEXT}\n\nRemove a user from the Departing Employees detection list." +) @username_arg @sdk_options() def remove(state, username): @@ -81,7 +91,10 @@ def remove(state, username): _remove_departing_employee(state.sdk, username) -@departing_employee.group(cls=OrderedGroup, help=f"{DEPRECATION_TEXT}\n\nTools for executing bulk departing employee actions.") +@departing_employee.group( + cls=OrderedGroup, + help=f"{DEPRECATION_TEXT}\n\nTools for executing bulk departing employee actions.", +) @sdk_options(hidden=True) def bulk(state): pass diff --git a/tox.ini b/tox.ini index cbcfef1d6..9904dcbd2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,37,36} + py{39,38,37,36} docs style skip_missing_interpreters = true From 03acea3dcb40cffad424b44d5d201686b154515f Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 19 May 2022 15:42:32 -0500 Subject: [PATCH 314/349] Correct release date for 1.14 (#370) * bump version & changelog * style miss * add python 3.9 and 3.10 to tox * add python 3.9 and 3.10 to github builds * remove 3.10 for now * correct release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b84b58ab..9b8ea6227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The intended audience of this file is for py42 consumers -- as such, changes tha how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## 1.14.0 - 2022-05-18 +## 1.14.0 - 2022-05-19 ### Added From edd4877e7e0df198cd9954db0c40824a504842c7 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 9 Jun 2022 07:59:48 -0700 Subject: [PATCH 315/349] Bugfix/watchlist csv handle extra headers (#374) * allow bulk watchlists CSVs to have extra headers * CHANGELOG and documentation note for watchlist + update-risk-profile * clean up doc note * clean up doc note --- CHANGELOG.md | 4 ++++ docs/userguides/watchlists.md | 20 ++++++++++++++++---- src/code42cli/cmds/watchlists.py | 8 ++++++-- tests/cmds/test_watchlists.py | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b8ea6227..3f3aae991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Fixed +- `watchlists bulk` commands now accept CSVs with extra headers ## 1.14.0 - 2022-05-19 diff --git a/docs/userguides/watchlists.md b/docs/userguides/watchlists.md index 3dbaca04b..b269a1961 100644 --- a/docs/userguides/watchlists.md +++ b/docs/userguides/watchlists.md @@ -57,8 +57,20 @@ code42 watchlists bulk generate-template [add|remove] If both username and user_id are provided in the CSV row, the user_id value will take precedence. If watchlist_type and watchlist_id columns are both provided, the watchlist_id will take precedence. -## Add Watchlist-related metadata to User's Profile +```{eval-rst} +.. note:: -Some Watchlists store related metadata to the watchlist member on the User Risk Profile. For example, when adding a user -to the Departing Watchlist in the Code42 admin console, you can specify a "departure date" for the user. These values -can be set/updated from the CLI under the [Users command group](./users.md#manage-user-risk-profile-info) + For watchlists that track additional metadata for a user (e.g. the "departure date" for a user on the Departing watchlist), that data + can be added/updated via the `code42 users bulk update-risk-profile <../commands/users.html#users-bulk-update-risk-profile>`_ command. + + You can re-use the same CSV file for both commands, just add the required risk profile columns to the CSV. + + For example, to bulk add users to multiple watchlists, with appropriate ``start_date``, ``end_date``, and ``notes`` values, create a CSV (in this example named ``watchlists.csv``) with the following:: + + username,watchlist_type,start_date,end_date,notes + user_a@example.com,DEPARTING_EMPLOYEE,,2023-10-10, + user_b@example.com,NEW_EMPLOYEE,2022-07-04,,2022 Summer Interns + + Then run ``code42 watchlists bulk add watchlists.csv`` + followed by ``code42 users bulk update-risk-profile watchlists.csv`` +``` diff --git a/src/code42cli/cmds/watchlists.py b/src/code42cli/cmds/watchlists.py index 0873fee15..f4b0c7b64 100644 --- a/src/code42cli/cmds/watchlists.py +++ b/src/code42cli/cmds/watchlists.py @@ -186,7 +186,9 @@ def bulk_add(state, csv_rows): sdk = state.sdk - def handle_row(watchlist_id=None, watchlist_type=None, user_id=None, username=None): + def handle_row( + watchlist_id=None, watchlist_type=None, user_id=None, username=None, **kwargs + ): if username and not user_id: user_id = sdk.userriskprofile.get_by_username(username)["userId"] if watchlist_id: @@ -240,7 +242,9 @@ def bulk_remove(state, csv_rows): sdk = state.sdk - def handle_row(watchlist_id=None, watchlist_type=None, user_id=None, username=None): + def handle_row( + watchlist_id=None, watchlist_type=None, user_id=None, username=None, **kwargs + ): if username and not user_id: user_id = sdk.userriskprofile.get_by_username(username)["userId"] if watchlist_id: diff --git a/tests/cmds/test_watchlists.py b/tests/cmds/test_watchlists.py index b26607b4f..fa76e3861 100644 --- a/tests/cmds/test_watchlists.py +++ b/tests/cmds/test_watchlists.py @@ -579,7 +579,7 @@ def test_handle_row_when_passed_all_headers_uses_user_id_and_watchlist_id( with runner.isolated_filesystem(): with open("csv", "w") as file: file.write( - "username,user_id,watchlist_id,watchlist_type\ntest@example.com,1234,abcd,DEPARTING_EMPLOYEE\n" + "username,user_id,watchlist_id,watchlist_type,extra_header\ntest@example.com,1234,abcd,DEPARTING_EMPLOYEE,\n" ) runner.invoke(cli, ["watchlists", "bulk", "add", "csv"], obj=cli_state) cli_state.sdk.watchlists.add_included_users_by_watchlist_id.assert_called_once_with( From bf561b1e7941c8b9d392f2a0b78c9dac15a3d403 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Thu, 9 Jun 2022 12:23:23 -0500 Subject: [PATCH 316/349] fix a bug with devices list --include-settings (#372) * fix a bug with devices list --include-settings * style --- src/code42cli/cmds/devices.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 36f53e36f..12b2bbcae 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -443,9 +443,12 @@ def _get_device_dataframe( def _add_settings_to_dataframe(sdk, device_dataframe): - macos_guids = device_dataframe.loc[ - device_dataframe["osName"] == "mac", "guid" - ].values + macos_guids = [ + {"guid": value} + for value in device_dataframe.loc[ + device_dataframe["osName"] == "mac", "guid" + ].values + ] def handle_row(guid): try: From 9fb3656f0e107cd366af9b642ae2044271b94426 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 13 Jun 2022 11:23:19 -0700 Subject: [PATCH 317/349] prep for release 1.14.1 (#375) --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3aae991..6d73aa240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.14.1 - 2022-06-13 ### Fixed - `watchlists bulk` commands now accept CSVs with extra headers diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index b9f68edb2..4454c8d4d 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.14.0" +__version__ = "1.14.1" From f606b309568606f978d8d739d8c409ec77eecd4d Mon Sep 17 00:00:00 2001 From: Andy Moravec Date: Wed, 15 Jun 2022 08:01:26 -0500 Subject: [PATCH 318/349] fixes #376 update code42 logo (#377) --- docs/favicon.ico | Bin 5430 -> 15406 bytes docs/logo.png | Bin 24927 -> 30951 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/favicon.ico b/docs/favicon.ico index aae2460d6a39cfc5ab3380cb7920867609c76adb..9a23a8e7d5506ca506e71934f93c3bded90c96f8 100644 GIT binary patch literal 15406 zcmeHOeXJct6(0lz{Gw9PT1|Y=Diw`1)(^lxd}0;aa_{aw^1wumk5sV%MbsLN2z@DP zsSr~EMNmNjV+9mSsg_oxHU_bfJg^p2ir`00TcA`0EG@j_?>DovcXwxI@7?$AeHPM5 z&Yjtr^EESP&z?QASE>Ef{%X=Bg}P5II6$d)Dy91R%=L^{D0LB3bhbc7! zI3NLq=|@$sB=RN|$!fqYswqcyOx!OAs92T)b^|1Zr&S_fRfDpBM||eUfoh(-1#;i3 zf$c$gQIM8+5%^Q*b|sRdQr!1ssIfer({(e(uf_E+>a#SMHv2Z`@E*z0{~*_I=UiPU za%D!(24%F?YY*DL2mB52*R;((%X7FuYcI^YTbv==jX9i_qg|Ty+eSl<(am9b0dUu* zxR0rZyi!9XJ9Hn%h{F;K&CnF;Y1e+PydGmuYWzelM17HNvu^-nEX0lFAw!H`v;KEy z_R-Y#!+HEW1%X}{XnfjGEO!GQEzuKyNcS~mD%$z=+5g_9)-g}t5B~R}|DGJr zH7OqXuF3V^gXjw&#Ow!1-8qFLq0r*_an?#xK*S$Bn9AUfM138K0BnwV2-txR(~7 zJ`eZR1*i$G0NAg?Lt!IR#qu=-2*PpS^-@(`r*5++$j=bEGI5o#&nFU#V zu3e7$fdJ1xIkaK!Z+%%j_WEU*!%qY2 zHC}mn`Wv(?M*()gHjA-Jo^JeY9V-OvKd(SehYh$$h*gPGRKIQOEB+AfYiJkFS zp2E9|``$5ErT}+UMr#|!|7<`Eaj@;m>PWue;lD{g=S|#upEFLHaxv?97jUcm-}lO7 zYL>hZ`}jwXj8m~@A9VU-;GPbea*5^pDQ=Z~+I(Clg`5G8oDI;6^C{jZkt7a%agVCy zNAeHsLn(fN-!Sh2MOm)wd!1)2d68!ew_3iz8}`h>zN1v>Fyx^d&dDZiC-QeH#(nu> z>?i+DT-M`p$9{QV$Fj4n`~kdc#`3!o-r0Hay4|v&vTXXA;=3~mBKb^#XDI&A%(2Ib zd=&WqOldai?)*<8Cl&a$_LTA)c~sqwWuwM7`~P9bHXH(U&_6}81a!NgPtk{bKwg6W z-5wowJ2uOl2K^A9C8Voc=;s@ZM@z-sC(iw@5|)D8S%5yLf4m)$Tn4_y{b!6gQ6)kC zrz}4f*H(L_4|5Df8(FjzWLFlPz(4=0vMkC9febhK`Kq z_&)qBpyh`Bc*~uGozr^E^*HsBF+6*^HW%1R1HLhm zA7Wkq<5_R3YpC7{+AF~0n69Z*hY@oyk^C5XGv^S}v0Xpsa4qI=cpcGQ#bpdUAn(Q; zJ_%he1u*~2eEof}_4_rj%|4D9oHJ4XpY5m3D)8NiaWR4IeWk_Za7kn`E zi_qpf$b9JZA=IZ}oVggb=L0?sSOg&M4FKjTw*m4ztJ{hEs*C!UWieLcnHlYm&*Xol zz&86hMw~KTo$Fs#=zm)m_}9^;F77DycSTTt+H@qp!5kPX*5%!6eCo6w{_*&r{yg7e zULcm6_d@+|!u^i#5pe&vE6lw{@ck`6+UvEg;{POcxfK4Yvu|na+!&kl{B&%wag{!G z#(3YKJEj~J=V9Kd7Phq|=5P6&Hs|Q)>-CD|D$xBXg{#uO#i-AK9W>Fuu?^o*;4aiU z*fkw%%{4Ijy71a1@_C%CssGBm38MepGzyTwv)Z|>@+disub79gZa|6l{|vX zF~C^0sxa0mev?+#fw9*7Ad9AsYi&GMrsef!=rAQn&w+l=xtk?Ost+2x*HB|Xa+C<*g$^xlF zE0;>%o$4>xvlW%T%;d0ap4RG}ulb(#_23*6 z9O8xwWfXHPd!P@`Yue`zGoCt5p9(o#)BHCcBNjPLtH z#ry|?v8P}E7;$OUeP0l0X-S>^S^A|2l~v10bc|3 i?e+RoL31DQ{{}0d^L6k!M+f8A=fs5eDfxU|f&T$_BM}h* literal 5430 zcmeHL%}N6?5T4qKf{F^F7tvLBxBh?^AHX8$RS*ic-E74}RTT9N^a*?pAHz2gR1mya zZ=#4v>!B4fPP#2_v!&VUY7sXu&1N!_nf)f2$rd029WXEe*lVyI126$V)12`s@=uUA zjXZor35Smz0P^K#5XM9DU~p%U=e79eW8YYs4w{6m8#cS~iPN>vk#U|#y^Ns#t!fS3 zF09JBwXtroj7onl`OsUF_tVSkiFfv+SCf(T1#Y!Dxi{@X)+_JDz7c*?q&SSaO8lzp zSK?=;3PXD4X|%=o{j2jJ$0&LR(3J=1r%1d?_$+v0PHrO4Xh;bpk&h}(Bp#{xi2J|7 zJ;cLv?!)+pwb-=>=%r5Bp`IzuY8$+JA;&u>)66hzx@B1G{0GlZ8SBL^BzaUyVXh&M z=oZ~UA4&oH@v3L(ipx;b$2g%&f%;&vgQf->Oo|u<^aYk+F>k=ye=E;4}*H>3-s_eIVZ&h2Ymr&U^NPF=&Ew)$hvHwvJXHd84 z1swAZ|5hEz0M8#c3C*=?KZtjC?TxZw(Ph6r1fGAL`rpp_@$C?8d$%MfQC#jIks|s3c|IVvIfeGR7{6u`gp8 zOZFII48zR4w@1(K{p0=f{r5N5 zS3_;d?IMN?7tkZ@LF1=}j~sgyR*t2r;JzwU&scRUwW~uahS1`qhTdHRSboG zq4&@A_ou5!1voszomFWrk1Tan=fM=bg#%@ofT8h$ng9LR?WJs-`1`!bxOC{?@xn!A z`M)o(TtPA%JO;izO8oos-MiHPMmT-C|H#2xC6!><-`^iR&}RGlc>MVP4*D-C{+Fiz zD^mXo!vAX2|1Y2frQez9(Mw@GfEf5zA%;dBWjOJS>ky^=$xR=LvnL9Ug)~gL%Ir?Y z93Wb%%Tc0|L8s5A-qFp%PdsfSPi{(`)wH$re{?X$W@swk_pEc#tPCLy>rZZ;op3wh zGD;f*BXnfb|Jy`q==f65ulV(a9nh8c#Ib0JZEwj_gDKn12O})}qb&dK@ohf&s6gt- z5o*%>EethAi}l|h14RKy%5r%uj7pHyO!vIuZ|f)a{$iz!sy5qi{o8zC$dDNExo-SW zCWRo0Ie^!|f|H=*DbO*H`Y`ymKgY(h7Cio7!jGETY>tqhX^MO&jurlsI8ws+yR-0s zvZ+SFuqQV-=eqeTIr)6xR1{~85l`J$>99*7TitQ2_J#xmQhz7Z~d>4ToqfH;wN zW^`mjsFi`zn+r*v(+->TdhLn|v})#a=&V;A8Ue0Umbi z>FxKyQ&{p*Y z%fUC<3t*~q20k|Sj-&$vsPq(&l%dlnGybo^gx=F;6W-AKYCCIusO_Ma5*ASJx&B`} zS|7y-zS-v^T}G)1dIzJ_YyQ72Q29vy6UT)9TG!$0fIUA;!m*D!6V4nA`#?#X%{8f< z_IIHlQ~4@^gxkAN0E?^-UZ%DkCDv<{RRkdP>u(*r>b3?8&RR0XNY3aG=MUZmj-Tp3 zB6&Q=i{s$?ld%16DoYKiThf$+SHFQrP`UW!_CG#m|1**v7%9OyR_>rx(QP1@ z?*qv<%ny``ZulHn_IJmv?zd_-!~=d6vG2DDCliuCxnDTwwz2cqj5<)UKt7yy3T3Je% z=~?E3AL^9aX`_xaZQQMgivsR|o8a{pU33E*q)e;nVZCfke8^jN)o z?=sEfkJ>b2v?3Uc+q|0c+^RCa7Sb>YAaXn7T(qY3Qimj> z&9-+PSe79D`24}n6A=#6kecGF5|syy*XV*_sGf2$m(~pE#1;XJ*Yg2WQ*Auf(+jU` zMU+B=$%Qz=kHVGvanL69f{=qb763oc04JlZSt$`52*M5k;M_)+kRmU6Y_;n<*&_Rm zM>RdOq|cU?GkXF5&J@USs=v>n)5r59=(f5o+nGIYAa3xEExQD|VmAJV-%j%8-pE@u zfw`dr@ygTE))H&sA!EUk#^dwHa~=Hn_4^A82R+KVu6>dOsC}Vf{a_EC$*IW}Z=^S~ zZ-r6Ys}ZBFGPwom3PPzv3TpbJ@F(LM!Yi*nd#@Zj_aD+ZlU(M*6kd1l+@u<32M6@D zJ;EHtO}N}wrNEM#_sCFl-ut_MrIQnh%-#S%5F8<@Ury z%E<2hRrqHD$n3MX!s?tXg}IUo6lr*-a=c`1fAWjWwH{APz(SNLypB?9A8`!!&O+jz)a zZ=y2>%Snn?$4>Ov;_%r$U4=CHxgo`_9kqj{JI16Y3xlO!0PUY|lZM8&Cj7}O575P# zSEL6uWieDN)H-_S-~^_`&$#qZXs#P^{=_lD>7Qwqs-u20H?tKA1%P6N{5Sg$0%z$>6v8`7IqxG{WYe6o|Tu7gry6@{W9hKp4C zPu1Fo6dYQl~ATK33V|24hJ-e1B2^j+%giMliYgavR6Y&x2jS-CpGs)Q!BKlyS3 zAE~VA0Hq&YLC!1VPy=9C2W5{1LurwXq7T~UwcJfMa=H-90_aS495etv)pq<}e(B%W z#-f31IRo1J1%YMQjZPp$2}K`tbTG2wF2M_pb8(2dp$hJBA-w(pdC%O_#@_!cr6tz4 zv06-0ulBiU$574dX<19!P0qho~x>RPyuYC9KiK!=}i|IpF#U% zErfRct<8~);S)N!1(6?LNS>vPI7|KW*AoOz6gKH8E8J9iw;&^Sgg>lrB<^t3Wv-Pv z@4`8cv9i>FRgIs8io!Fl|L!C(o^i?eI^ZT4UQ5P53zeT0{fMAO&+`aBFh->oLT^7l z6z0yq(wboj+^vmO-UD}QUkC#Bfs`Rc*;JL5K4XhKr)g`Rzde3z%KE&PP>SCKQGC$Z zW(Pg~`PpB=c^90T>;P8A-OcY++DXw658FJlu^5A@Le;I9xIyaEXyJu;oTTceo!(EKpM`~#+Xo&3aF;Ciy-oleDa7B7 zi7eSAV)Q&}q5}p#w&@f&rj%E80+_M?FfY)Fz28uHuDe2cD>pfr-ih!qAEzWK(c&*W zsa?ME5Ku_`FF}VnmEoa)QO|Gl!54b~BE?U68aQbeuD~VAyl*XR#5+jdflK_?_?R+W zb)TyMO7m@jVC10V>$d8p;<&`aDw`I6H$Ayo34{;&{495(!hdKj_cFEaFwnebH_z_% zG)ZsfCVuoaYG*vPkwY09Dtb5vwn^)E4iuX7Ibd1TP6b;Y9?ar;^DGakvW4;wzqJK4 zwiJzBP5l#jDO<`&Y`Jkm_HBgr~LTZ$h6}Wg6d$E)HSpa7t!&s zjI{c$>HN^7#$eU5nCH(%m-@YUMWb!`&!eT?wgbcfs_Z0;tzL51Pc337RdvI!7u(S_ z*fRSorsM&BZ$GNEmA0h~Y(#W@9zkR^ZpT+D39=i)n{YcoWK!FHE9Hk|L}Ke>QhceH zpca6Cx914SF(JNMzP6CTy^DD-R~6<@UGk4Y;JCun`46W>KXvLFn&0qqd^-}FYZRBl zdDvgfYBHHqVnWVx*tKNweu9&%DdU>L0N>aOffxSB*V5BXddCoj;MxJ-MJ7Vx{&3%X zy}O2)maK^Mr4XCLTy5+_7F(rOrRmGP@>MHEy3 z4wQtGe+K}`A21kbyPt+jZG%bhA7gZlYQE2PTy?aPzWX&MVFSn{@wYqoeX4J}tG0(NJ=tYS z*^#Qn-}c+A(~^^Eburw>nD%YnVMBOumZ3quEPQ#*Ki~V-$C^%KmIicc@-FI^!roph zymFE+^&~Hv)%R7VQ81bBMQg^+4|wA@NGo}(LL5-Ip%jpFE<$6M3%^2NMoOhjkLg-H1~hkaG8JF&;Oc*1HiPo-)kW7ud$5-*FQP)VC!e=idl{@hQIi;)%kSbG%;MVdj4!L}ZwnQlSnRgY z#yaKEOv?tgvI~fw4b%ypU2{03J&Z`5c6ZmVcPqLo&pdCq?i}_T@c9CgmbJw+7e5~y zw_q=oBww&|6aam(8xQSdf8TWrriqTdhSht_;?V0t^K5yYWl18n-wo}wY>k#3zF7Kc z)!=4{{ zCa3t}ZE79EQ6g=d0Gw1vitqh@ZYzQx@9OLbI za{0FLYrW$Mc7l)evHT{>rhOKE(KRRgk1s9HDfnl1O?%hNOO5CoY0yfkM`VWiP50fvIF7Kvh zvYrnn%T%(7h5NaF7{<%m5AEq(wKWxohc$cmQ5K-J(e)Ej;(U(}CPKxwWo%889S(&x z+S35IKq~Z|S~D`m(wHtI1-QkIMa)09`g(M;g?+e0+~PH7_UAZMFgaipFAF=*5wY6T zF(ZTRLS|*(TX$aQ|Nb@KVY;qYmY44y7@UKFXb5)U26h1fdgS(A#Db{obepeC@+F4L zRZv==wiA=<>^y+{uiw}~MTc#~Q~?#Pz{K|4Dju0mdYfl#A4A`J621_um;Rq1xSN=%AzLTwoU zM#OZij_CtR4&eRB5di% zWRwp$%Zm0pPg-TCyK6BHJFTdXi=A5NWj}?3N9m}VSbF#AZwDI>#=N9Pn)n-J`;JT3eRx5AcStLozNS6|z9Ye_^sADA$N**v=(46A-1PB9dM zRnI~k3y?|NzQgH0?>EDKpe_D39nalZ-x2*edHaVdV==Ty7as16?G`E9}hL&@&&K}#)*Yp$C&ALQiI&W}-6h|#$ z%F&2|d=mlR@4G)(`vhsr=hv=7{!qy?0 zUICHsC954>YIsv=U83)VBXV!4Rpo5*v~s1!dWif>f_ z<*nZ{bNY924 zY8fpotmMy{5(;f5Y}h7=k#7iD1#tC3Yy-y2PG##B5Ss~9{(^O3c6@|Mb+U!InI;M$ zX&KHK@ciib`XeK(5a8RrB2w&@-}qKKSU;jY1I0JU<@8g3>D!A5CJb3rL|S!sRPpWQ zrwtWdT$+y%_pYu8@H(Nkn#ag8Rf8TO4ssp+n{~LFox{!OMca#Tf#b`@V9kbL&0VY$ zs~hJ6W{>eZ7q6{-jXc*;8Z_xLH=o7juyF^6Z~UjQw2=_TXoLBz78#DKt{n7;Fq|8_ z4Pf)J!eaTbU19nKhB^=x;W&&gbRsxZANqYeEgS`_98kGAITcZ}XRij8IaM2=i>^Pf zKM6If^{3rc<97D3jWNTh(k+`{osw0(Z&=Br79R>FQ_uoSb&?W@Zx6J!xBwSvt!zGu zC#?JEoj0c#+)AhqZ`L2v-?~zCGh;B~)Zo{{6&dM2X3baTgJK;&EMHG}KBBbCyYuv} z1zNu^CjQGNy3d*FZ?XGu@&*voCl%x&dvcqJ=vFv^NJ5cG^gK*wE((=S9AB(rh?SSk z{Pu|fmW{UZZdTW}wylgfmm$wC*5UN&IeSwjjdO75p}2f|bIHqhbqIyZSKZ_xJc4Fk zZ?$$3p!Yo6x}#Lmi18-TquikY5fGJHZC`wR7R#3s((Wp=q1}`uG{Fm308!&`u8a_* z5!HdJXlbTNt)8WACA@g3kisBOOBwAZ+wEb?^kqxuH+HJ$BWqfY#p7)HEmT#f{N)4Q zAWSTQ5w=|2#?aIpdA?wLjP_De+GrN0Ez`Te-(DiCh?Le8toYfX6@Q|Kyhd3BW^MK& zI@&S1l$z>m62At9RcC|;Df8QSF1(hbJAYl}96x@0-i|(jKfaPi1-|GfKICu7jQlJF zc#h)D***_pCb*vLCn;k?V9Ikt*h=`q4gwHulo%kDY?vD(>VNtEuvw7~M*l^89oMDWv0gf2X&PYa;g3}8zYeM>L&zK)kzJd9&kEoy7uBC4s4+Gl;V=MrqH z$&>ub6aMB~vyy#efEE?xJ2BN_%ajYjH3eWTjWA`n#eUQ23dcICthbZ#aH&W4x&ykX zZXO8x59&nEJd@f#19|8Gxh;Pq0cAMb<{c!LW2lYXKVeyTY84}hQ7(x!>Og2!VIpgL z$?GA>p_?17)Vn#54N}=j&PSxS{{LzLJf%yyjyWR*E>h(3WLHKbrjf$FBOzQnN0x6F z@`Y}S=aHIN&BO2(W}1Vz*CI%4w!lR=eu%2P*Vp3bY6aKI$r6YKwl)TYv0IN9cC`U9 z?7(TML<0Y+yD8PewJP;oblv97%3m_d&I^mJXHvw5Hm_v5TuM}2G?l{a@1-`rBH~cN zO;-(h#zlNv))yg@In{31S%;Ebt6bt2Wi698CWKj&+`~HBDQavgWZWRN_D7uq$#!L< zx6dRw);=vh`ONfqlZz&_thB!H9dK7+XZtaHnzqhI>jKKfSNiitzzM8GT^wYQ@O=J9 ziDrH4VY?+rT+WsZFSiJ*tkG1-`tLEEr&K1!WLQe?`7rmgyM@44kM}u3@+aC{tt4SPbq1k4~O>2?1!MLN~A} zt_#5c?vQWt`)F!o6=lvJt&6^(-O+05DO@zvxb}**ptHm%ZuN^xPfxvt5#}y0%VIuB z{7mhHOaANA{n_jp>3Nmo3}QcB7HTd zds9qzm+hJ&kP?oH_=i(ryF#ndiv{uFVkq9i7(GXYZ)wR@Y4eTMNT9yS`u!C_V|`Pe z*G_Dczm&6`M{v3dW_n2^cD*E~fm8%TTE->TIV=5s!vgD)n-GE{I0^Q+O%2G~BzrkH zm6O`TWG>1*)>lb_W^RQ3s1aG#oa05# zv_Ychv^lYh^PU&BZ{e|a`qGWPr%up|nFwyNj@n8@DzehavXt5zR3KmY^zw1jlk?x0 zFH)Le_^87}y9qCB)nVDZjEe%rLA^Lcpv3prUFT^xwqWvHe%@=S-)iY-jHAa0`*X(928>?>nSLv; zX+exKJr?UIUj`@R(@yT{;}33=L{Y)Mfl^^#U4DEiBv~)RYnV(4*@vr3rxoT^8^T_! z5b8l_=WfH$j@3%S^O|b`6%M{?qc6&mA~(8(7}lNNC$snK9abLm3dU8JL5MaCVIXsq zb40AyDz^77d1r6*h!}%+_F7!1T3Kgqbl4ahePQPo{fJo4__W8Nwm$V+L*>6B?|29i z_%3vrw+Fo@CMzi~n=8d>JN91pdK+Jcu@1GIPq`Q2P_4YbdtUEHd?U&hcSnqQj}m`v z-3pefGw7=ji!N)vYnrgkD%+jTS@0%K>}th`Kevd4c?i4U-t#HD=dz{h776pkqUc(U z%(cinA-+Y~;ZHqyhd)}Q1q{B!5V{VM2_Kp8nSBM6`N4qm#j7c;zG83O&hn~=G}8~w5NdK(Oz`HpB|E}3vblNnz4M`Ex} zi2U*?&p_WW((D6Hq67Ng9tO2X5RmyF1AYm6gsI(6C#Ha9L|qP?@e@Z!GXKmeHM*JD z6~#P8&!+g=ob}Vv>^A0A|DQ>F@{sCuAcRlvYf@Y-KHI<2_m~kvK z8t=m)_Q&XMV(w|lStCNI;1hHltZ>-3r#Q8gvk@=xgVR-bAbf*Xor*y$RHT{#FR)qu z;_@HiVUJ*#yEKStI3F(1)4*PW5EcJC4G|SA>s<94@OxfWnaFcy>swI;={d7Vk$T^; zd(n}pf@=EG3Sy3zJICG#jn%Y75Kkkpd8Ec?76g+?g3{joPZDtf6H62KM-De%OQLc1 zE6=NX`lrjG7DujwsQ&lLk3h&`BFvST_4_P02Ic;^)YckF7)fqe1gXdSCdKgcRO?E#9Elx^NUCEKP{PLOVcE(9Im@HB@!)=KbI+jHghHP zq0T>KBi>o$?gk43dHWjDRqWtr46OER?>fh)XRRd`K{{Q!mRHl3PakZSHvT4|*+qYV zhD5(d3$3K4b~7dWMwp9mFmrMkV406Ev(2>VyEHfbj%cJNTRJ9R#{<>LOaSLL8EAS(%9qoiKZX)x?{ zUfAI=8+VY&Of8~9qE+hqf>(<@D;JQVWyZQ{{32@z{a&2?&@LjMw0eEPbg2H)2;%$w~k)HC0Cr2T{*@2lTCdS*zOp8jZ3Au1Y`X-m)ZLn$m`HKJ^%G}X5}+S8m| zQ1pou`K5oHGi7`xcu=-N(2;^ESI+ihVp`_+Rs3uiIh`pv8^ogx^3XNTg7?5EsM3}I|kPCR?Q?)^mP{x?znF72Z>(BMZeT{MO5Rs+@+bT zrq{)OghJ(Yj0VP)nD+|Pg>pXbb6V?6<%IF2LBw^!mzSHoJqa>mmA5Ng0nDM#JjrPr=1w#)RI^SzhfXOkcNit zBar`-bO%9)UUFSFm}hPMzPd-9yWZw_*mG`a5V6bQj&(`SL0LXx_W$;pVMjNh4a{`L zN~^2uqE(f$_%^ga!sqgK{{9~L@-vvt7 zT-5$HGj#9A-BBS^s{uxF^CH@}8KAf5glFrflwJ&3(nH3}qO3QJAk!|Q&(WCDiCuLg z%e>Qr2yUn^=gOCDc|_7q;h$B3iy&Z?ipE!hG4M?^{a*P}bRmo%Q$a&lChh^l_otNi zc+qqUqNU20m*y>wk2}8P_dpw4fbRbjaNVh=P8O(I#mX9VtyB8c?=DxPD^wiaU{L*^0(o4Yh<+e2c zyGI#K_3mJ)y4Sg1>#q&W+-$l_h8)#S+K%GTH{Tsh#KD@$f)|m#5IyGR;hTnyz9~io zqTma34CZ{l%?Q)IlOJzi(IX~mY9w6pFO*3iXT%wNCJDZE#;?$_$6ppjYOM4Gr4F8> zwPtL-0}QNkU$<=c-C4Eo?Br=&B=CvPyiXdCd=aE2bW5!0Wv7AAw$N#@U_GY?_S4EF zySBr~(?V7X_iI#yPA^}Xe;r;GzZ$20R&hgN?v3tyBLaJhuY%6=#F~Ux@9OxZ+r~gv z7ULJXmz9|wUl%exF=H3jv;b}D!|KRmVhEOFF^U8`hT!KYZa;$~%JMZpZ%+4=n~77r z9nfRvHEo@UjmGKLOTjas!bhj>qRAviWGQtaRLqz&z~!_3BTJ zngwftr`e+arPG3$@l-k7P2DucBi^T26IWnyyHT?R1x`1(S@nk?Vr{R(6YMK3Mbl=9 z>dR)nDQS0@{yLtM7r5LIckul~Iu>rL2XTx#V&B#3sTknLEN< zyz)Pzp5-#-WO1?HM68Xq5{oEF{$nAxn5qaY6KY>MewvETt-|`}cW1{Cqo&JARrfC2 zG1BYTEhXv|hfWHF3EdyfGyq4YFC<1v|Enn{haKn!{9?nVTqd^qjXaey@uly2Fsyvh zRVt}!P0agq)l~;O&GbLMmwSXPAr$XIkkxa$&Y<^U;kS-Ew_vrJl+vyAl_fZ01~-^Q zTf~6`FcY!>{ZII-gr^(oT{{D4|1`^bm?GB?_dZs&-nERxWo(w5dqurBrV5T)T#WRg z`tT&Id(dxfysLZdeWC}7!JZ42OSL^pbnMt=nU6HQ9NeFQ!#1(7?Gy~3@=o%qz^ShY zj=W-Ia5w0)^sTgcqk&9>HZ8YX^T^y2gXua&z{poc9%Uo44OXHz%b}_R-U%C`*8UmLccL%tuiqGq4iJ#T6 zLSIaOv62lS{LAvT;%H*mBlq7pT8u(o;h@)iKEil+f^Kg&P(mr3_6 zIbnr)m62U1`!}>{zdu)Nac`Wz7f4Ru+Hh?OaXvj$W3$oZIzjpo+NE0 zskI4KA7{5A@iH-xOPBsGWw7uA&FI}ecFDWDWb&r1lpDvu!y?^0Z|e%^1=MQ~w4U_x zqWl>)j}JLMzb+(e_y+#umC@DHuq>94!tCQcDaCv2Ff~pWV_V$Q_4<;GvNrZ^na3i8 zD{t=Vsux>+R!Rr)i9SlL)90BtkqM+qZpt9);lIpGO`P0wkxRw~5PX94X2tg^3D2r0 z=2a%Y$}NaU4d+2(@;Y9l;zbaK+c&0IR?h|O?d%|a0^-v#z>36%$H)4*C_a%-Qc5!W*IDLz1 zad6mCXDwvIhzt-3<#p_+MV zi4VSLlIB>%{4jC`hLv$QyPUjKc%ARSTQ#hM;_=+ihFJMujv@;U@nxzJyFwDsXUP!1-g!;> z-}(hbzGpcWlg=#58_p&WyWq!tDg=fILvIQU##ABj3S?Fo0p~XcTnc4SzfsecBTZ21 z*hN&PWZyVS91E-3tN`ikY*5apQOxkU4P+)kOep=rvRYTW$Wsdn+*u{(CotHf=Vl>U z7!wC4BN1D#{4Vh^^}Ve5YTCjdxluE#m&7jge#p>g!l$y53tM~(OSWZ~p1^$8Lnxva zr(S)w#5+IT!N1~;c03y_0xEZMDgcAq?~X@9-DA$K{DcfxlJd?)m-=9me2ICN=b2Bk z$rqUvIE)o9$?ypdsrPa5I>#h9p!rP*a_`>#nsOhH$j!2K(t)Iq#~b&PVA%>^i8)ze zqh$#vKo#=JMx&U+Ue-=j11qSsYWCI7_@;6+vutcH-@$NT)59WoZz1zsbO1PsJUV5~ z{+4l4w5q_HP&OAlJAHLD<3#p6>P9|AFb+lTe3qyIhja*@f(mTF2oa7>gk5i;kzyYF zxt4PYIvQ*auWcvVyx+1AcaMP5Wn1&eC(F=NZ(+#Y9!;0ikO49AP4imww*(*aKes+; z>GLVHd(!dcS(P~sY$~vqUm5bwi4f~|cM>g{7~#Z!^$Ra2Ni$uT$jzGPb9Y?rr3PrA z7Oiftc!7j#FK5ACHgAW|3~k$bh)I1~GBrAnm<+5{!MslB4|{qYG$&)w16+8S&X9mNu(ohW{PQOCQjLZbq_j))X%G@zk_{($1^NEy%j za+t3Hg{c$@*&aoO6g3T&Vw||*Vzyv=8;z95To`L}-b44FA0_6^cQqe<((VDlwaFu znG&l{4Bv}$^mZ3zo3FK)ObnDKusDg@EaM`>z?2fCZA&#-iOsUtowDIziym<#9j_lN za)C5K2vEgwf+DQrcK{)YCkJoG-8fNtoz*#R=Fn)K3G`}PN55zp2J0q5eS$8ze0Y-O zo?2x8`BbuN zd|UmVhcvy~H1diEf0^K9sHefUSw;sDhwo321%EB~T1d~?JAP4Bck$cNWE}|M>S%N_ z@G==HWt%@oKkQ+6$YhM#_W$OZ%d`B)Oja6tRj9&aZ_7K4m_gJmd}c;4kDZZJ(#D3g zc-`CGJ&ep^c$)VW)XkpkFZtPl6rJS+84BvKqDNG~n!@1?GfjPAVJ{pg!RGKWQw3T1 zw)=wpH&9T>y0UL=X?C>x-q!r*-P#vf^@Y=}Ui-p=eTd5@8=54m6m>E^IUH5Jc ziM*4tQz_4+6jeB&LVB~~wPpVAftU?+{q6_B1HCBXgfecT&npL`)ims=2~?o@wrWaS z0`AH1-C!6eI&u&@rqQuG>ke1|4sfd=??2*(PM&Wh3+3QK|NN?ShQBpvDRPjO(D~#K ze7QbbiI-y{Z7zW-9Oc&}roIMn&GKSFgds=)3?LcUdpj65hYKvj%0`x7vw?f3IX$!2 zxGwnykpGpJw8+}fNqpK7ZUt0K#(8CJppEN)bUv#H&GlQ_f{|BuRzHy~_8yJ;L6vrQ zAE$>)w9W1E==U|9?tx@r&kcc#4)p-Y?2^=SZC6ho%H~d+)dY?pt<6kUaqy*CZsDm)x-m^W^i?}KGmxJTA3gg?#@%R@jfdVS6C=EM`L z!?%tRh)=3wl4-2NPlok^VO`_>PWJ#+;#KBTYTV3MBRRL1-xN{3xT0bi*jbgn=weET zjwSSes1Ox)(}ZWe>w*c0lLB&Q7aYHO3D>-XC9!1$m)dju%(MXaH9mppH!6+!n_pb= z>HeX77DLPX5w@$Tvy^IJ%0uA49GO#a|Jm%x@@jO^Q<pw)lI%}ADX&1)xy`JB$a~a$*s=rlqIhYKSkN(t^r`EAM z5YFlc3aMDkv`x4b$0IsG)OO3isw68g?+j;AJ8<^+$AG^4}A@x{jKD{(vt zNOixQ6q(Etw<#>7zIpvSz=M0qAn<~;VYVg%9v}%JoZ?M zIKGtcZU4az9oL1Hf`5b^wa$kSQHyCR#nL%hW36O@v@%?Nf@x8*@2y%`>`%$6Le23L zAloM-5LRIf4NS7!WfbwB1%m*Vvo z2#hsw&>6tkCIaVV)@CzT;NY^*@*2qZe(6C6fg0?w*z(mck1uVn#&2B;fQ*KfWu@I> zOE@)1dbFe=WWm}v4p2-YN75~JQw0ypmXL6=h$EA}yU<CPdI6?ZFf&oRK z^0lai#M%m-Y}!#GW$4u=Ls`m8zwSQ)WnB{&?ibk~X|)$ik2&fPPUsKXicN}xaTUac zxK{Lwt%P3tW#FL-U+R)5X!
    tntY*dv~DMz?T-$NJf~H}+(;*u5&$;>BhhLT_Y8 z+sN>cG8}V!kpR*+Q8yftzU4GA3dml+uaue0F?hAD(?@^VvBVExamq}Zkr7@QP@p{| z9AwSFbdpJ;NR1`J6A`hBxZ;WSPVcqqer(Ep%XcbEUKXaRXnPoP-`m?=C7M4+Y{h!N z_S*Jd5lC%&xk&;k;1xEi&OEDJ(gtI7O#wQMh7QIqE6jxZ`22CG?(b&hTcVt-=>-Ap(LLqLp@h; zw0G8d*Ii>aIqjaVZvS<^D%U_Jd)&M3G^pY_-HX2qYVtB;zbC7z>POjY-ha+r{6oY9R+z{dFuAkuIdAX7BHy>teAyg4cR@LU*iLu z!TxT|FKTA@*afqEHpcSGy=WQ-GZ^GZdJIS{efRpTi(EQ33`{5T-pkDtp%tZHlBxd$ zlc^_r@p_<(<-2g2FN@xx7tT=BVLz{1SNlf}s9p0vHEEt`dtlNL~Quf5> zj1rcQSG^2b7D{+{g+2hMb|6PQlYg598J$|k57_=plwwkFseAQ?!#orlzyAFM($9!- znO^DnGO^>t0uqQXMN==l|DyZxq56IZ#llC2DM2#GX?eHANrOPB&CRk0@|VpKwuxH2 zaRo^@bdeI0sK1wiDX>6bo~}~c4zqrJrgqCaiKWbn0!m8)TP_7%_^9TpmTy0nHz#v~ zj<>H1@5200XlHB!q)x{QweuMlEg}9)tdPDTa9gDe1SV4d@Ke0}-(IL8u$Bp8`WQBr zrl#av@4hc>%sNvSQ8O>|_};be-~OuwxbDw|`SbR8UPhK?bhU}D$VJNJ5|FvcTyXy$ zb;e_{>|~WW`ynWyWe<)I8~tLaW%<>)ikVIwVj^vIlm+t5cY~WWT_lhG{SpcISp;hI z@{ql+Crpknx+i{)JVCIt=wn5}ppzu%G|v^s&g~@xCS}s0>^5Y>vzJEm_QBp>k--7h zySla9{2tsv`lu#&q{u{rF!$0^|2a6L=e(nGXZW1z*NbG+q5rwUdKp=O10IaHI!pva zm%eu%-7n)eEe85gncnji9F!lu+!XiTFu!vvJUS>f`8=pMw$rD%*OV|KcOKE9lf6M9 ze2ZGW_H2zVM$$>q#UYoTF*js5GM z5>K0M*Mo$CWk-$Kz^4A)2Z%`FR}M#sn+Ezj@m}AOXBe~gvt&4Pr}?h-q*o@Khv*zb zCs~)KRK@F~0p+obiwM`eS@{UiMHkfTbMh3d_)8@vdHyo;D5`Oi*LwQ%Xi+sn0o0uc2r^j@1OPFZR+#YnPX5x^~H=t>xOPRD^@L@^AZ)ZP&QPi>n z_J$_aBq z-hTnt^uV1+32pe4o_UAw&nNS&AjBh9GcMu2FF&yhOotB9slMjwFI!I!7dlt-Co+|( zJbQV$-4V8M{31mcvmg5khQYvXN8APCcY$h&hK*w&J0bx5-odD(?UUAmf%FzXRNg1= z8VTNuREFCZG<|F=Ijsb|ax}o&Rdm(n*nM!V@E&nhHf0W1T8jZGup!a|ucN}VUxmjs zLPaulW;OzsZ_<6t&U$)jU~>~kop@i|Jd^~kL^pk4efcn4elNJ%tgE3_ zsQSQC-TSTAow?o;R%Q7l&ipdPd$~@7mtO?XiL6O(?Vqeqq&>?Cfr)sh$py3Ad5Sa2v}_>r77ne z{%7>wU^$^q#}64VX*a;K&j>9AErB3Lz{y@QB6-^VQka!UFihr#iT#0#_~{<=UL1Pc zy4M%ncolkcU1y2m`7+(=JFB*x%PGf0k?%6={K-M9gnT*0!eIpYjMh>{jTha)B#9Z4DDns(&gg3+f0f|E5rVTIskhO@eil>omX= z2Ri4Wf==7x*!R%kX9KU=a%dEK^N6m0^1QM4pT+#^G|b6aojY?^T;9{@5TffTls{=kBqA3bP^t*u0^xjvz~Wmy5@_Tj&b3@W!3LzbbX|my>DkH<1(qN$y}ZR zdX^F9VgEWmncTKGQJL#B_`iFoIKCKXpHetx?J5Q+e|gR{VWs$-2*F!ehGVaHSh(r z6YGWjRR>6+vTv)a-;90=0fl{JRd26!>jvYMd8@lx0RCFJAH64g_jVXpnl4RtRbQ#- zpK^PcaLqg{Cz3Gfl~cGA4EtXA$)0=zNdLpPgq|vr%td*&YshS$o=c_=FjXX=vUi7A ztD7uZL%$Hwrw8>IfgKg_a+SPu`}0rTt{42zjI1l#izn#4etm?Y<21Q=kq`7|6i~m! z3^cS{nm4|^ovZeJkU~aZlD`b@nyQffYEUn{?N0$kwro8yrp`UMTkyejiF--y@Zn1H zECzG2)%Ht$*WN!3CO?j~8I(2t99{RZ;JkL6N#k;PyoDPR`a-7VqS6Db1h_yksPcf; zoLyQRcZw%Q+w-Q+a-z%cAse}G?Lmp|FF7oF8^^oYQ!axMukMJa)l1~%4Am^(tg0Lw zdpAXTz{UIh9$mgYjBewdc} zR(B{)V2*D^sd|C2GRB5}+BC=~gX===;VF(gdG#;oRhTclHF#naG{t)9_1WPd2YTwI zS-s@bhnrJY^YZfM-&_#hA=ZxTdl^9H#WoVthO3xce#lEy_@6oZVAW6M4Host@;1k+ zctuUu9xNDk_SN|2%}Y8gx({gRlt{Nv;EwD2beFPs)OF24R7bMv$1&!B zwC8HQI&u%QG!|odHJ~-p8&y$Uw+5#;+X>foY=bp_cX`BpTfr$$Oo3xf0rjo8Zrh<9 zgKghwN#1P`xs++RvhAk=GiZ#0gb`(Q>s_=I@<8t`J1Rk&tw5|Btgt1q# z+|qIR#<_Y+o6RZK{JYa@KV>`0V%T}YYSXW4Cn`->ZP(*;y13{TPW9p!OWq|zfb~Cd zky7GsCR;0aetoxaV2cjDWq%>hmN6J6sJB{vrch%n#(2w5xnaP}J(^T;>(ucopRJmN z78$ymH!o6>bRW3rDBb|_$*4lMl{S^F9Xkg`G0>M+Xw0%ik2~`b*v%taOv6YAR#`EV z%7rp&M}Kq^`|;&=jSGykI@IKNmUZJB1;63ZyEnx>Ty~1bh624&_H^}lz$LrOv!-D) z+$ykd+Z9be-{E$I%@;T|8)LrKZ5m2jAd|4YT*C=LcH~_rOGp0de<%Z&p&W9aBrIfa z?Bn5$05+Q9<$IP@{e^X)-Q7UOe7-PiXCi_x|$WCOJwZ>YO%AK)h ziy@U|jIqoZhM9RT>Hgl&AJ6~4*YEfI<}Y5K*R`GVKIb~uxz2UYsb0m6H)^h@-F3q{ zBpZ5;iWj5Ynv$Z7r$nJ5F1&{CIC07xZ3X&99uhwr!iYB|irtiW22hXoXDxjST<{26 zuKZpU)z>WAm0BCUoJ;uGPI#M%p`e;ucveKQYjbm9+Y{9eT5mse^L|b0;bv&>v0<;9 z4r;duO=8G09taE=8FRA*%f7~>CRi_t^{`sN#gRI>X9IikRPAKDw$uOG(bcsb2Du%S zuINy%u`PTuvA`S;BVyo>>|qocURs`D8~FoSS&DU;xYl*k9TmEgUH-V)ldb@tr;p)^ z#>^C68lWhsg4TxhE9uOe5@ba?w(IKhsqbUJI3!5>r+6nj&q@hxb^fC_lmkEAB0#&D zV*Q36IGo4$b0x}MJFucatJ?`Aeq;$Y2B2G1Xc5)A5Q==mJD{j9SJ*PhF#}_rsR@`2 zXs@u@_@YIBXCFD}_2edGBuC@#p6`(=vozb#SH;lzwzb06`rcokK!kNj##t8(s0 zr8VeK#G7Ni*EM>FJ-{gC95`&*rf*t>m`-e62;AYg$1qiDODy|rkzM}%FN(|; z%??Dv>qT*g3OpDnIG(c)fKa#39>pO@zBIWtrjbWWSP z)#;3^#6uLDA2ik6%))2gj>M&`a%l~R5gQH-STk#@%S#rp*qMU2AqV(xH`SGjET8Wf zb*$-$zZG?Fha0_MJ+@u?G}CA{)ACpq(%|5eqjcR`NrvE*2?tn#|6rgY<(T{RmFJ0B zhd^Zd5i#||sH&{Oqg6YBu5-Hzag7Xxk5!-0b()Ph3dNir(#u=zeS>%vkmumwbD{0h zwPGeMU-1IXukmxo9xN4zPTaUHCt2*a0B97RDDqK3{XVU~*A1>+r2rq+y`Hy2<>n>B z$JXuxu9W-4iOw6vzt*JMGu5M&ru;9U7gBFT?lVxF{l=ih50rQC{zkvYMDZddhdJVSv;=6}ZdiJa_0>pS(F z9l9Yr#~s`sJ@-@soDai@d}bDynmQoCZ7Tc&OyOI7y>6~k`dw}Nmm(%R9*QJ|sdYVE zn%=IhZc&+XfZexyOnlN*e@*X$E1pxu*zUvJFv89Bi4fuZ#iPaiDRZ6>16dtphHPsC zpuo*gWR|23@|I1oRkQi^z}jEIMTZ|fb2h&(azbZ|+)UqiFnF%jqfC+W?oy2qG%Vt( z-nyN!qlg^*ChG8faGiu=0DTxhk$tg(1?gXY2T>K$9gvb2-D^=^48QyM31g144h2e8 z`z7aU`d3btbc{$JppcJj(?x-=>w3EQAMW32C34JMUL&E;e5mLWAN$dqa#_=l&;X7pIKauV0B)oY2L;sf&CfIu6*V=NpqdRI4@DmZxg6 z5gc&ZobT3M^Zgft2=X41%v$Q4YB7%ujWBmu7yWBNbYw!Jh>Pg}6VAPMDY*MEQaNt& z@i)1v5=&|;^93|gt3I7_Rp8{ot-|e0{d3P3*)G1c zsW#1X?O2tj^eret$2Y(B1GyE5$kERY1g^%O`s&Ch0YV2I{mZ(HWn+HHq!>^0A8U;6 z#hF}Vt#({EO9{BeaU+B$5?DIkq=RDW`4S-egq6n}Tm$Mm!y=a`)r*P|5LvO%HScSV zJM($P*7T@mQQ1bsJWO-^-gO4_{gZh!(0$2m8mg_M&M6ZfC@0dxBk6;#ss`B0!mJfoSTV>w;JY_PG^x3+fg!B@$#aj zHpguHCgao&Uwhlj2p?9ijx>M`L|yA2eOxBzH|VF3p3mo1X=5pw&$vuhB$dtykn3`h zM>uXIOezQ8iq_WxQeBuh^e+hXx4MK94~0B20c9>~vpYMw*^m7&p}TAQhwuyOBOQI@Z8F?wFp6EC%Uw5x zmZ5n=yDWfZMZrsy@&^%ma zM~?7jjWD&wjeP!o*<9g}tXOU7TV<-5q)k6UIOHU+RUabzO>Q?mUGAbMeSK;ck`Dus*ST?!swp2NQ0;b5ACF z)tpSH4o+@UE>7w8=b6%t83>l0F>Hqs3}#popS&tCdxiDTQMo5$$&^lnvbHm;t2aH$>$;KGv4$Z0JDXINKmAd7S&Jy^Id! zP>HZpcki%e=9^cm zlCH|_)Po(xj#G5y0OO!|adt6W1NXW3RLxCZt+)Z*RS(KnZ);QTAlV;ntT=fq@V17p zR+oC(L?&v2XUevqime7-045Lr?vUJusElM=vy{2j#DU1oKeVHu z6QNl6-psXWq@8(7h0jjk&;gb^<-OIz5QOG38+3eI)K*KZfM#jC=rEIbd7(Ne?`jsr zv!Mo>4HkJ3@Y$+v-*svvzra94csMNK5XS)x0pjy8KM6aRJAOcHiU!OhH-sdU+m;U# zBmfac&uMGON!s@Fe6=q~oLj4FEP5#y!A0U7_~65~C@oIuFXEuySnOt;e&K9@Mer+o4zL>3dV$lbT<}b{2CSJ8`ObcQFap zv1F`SVuE3atxp(+PCq36RuB5ezCd85SOXOBxS1IrG0Qq-kAmq#NxFp-{1B(2q zPB)ax!i7JRT+K92?%-Lp?U$8RSkL5?lPX|gCZ&J_T;?dl89hzO;PK{!Nz&(7bH@Ce@{ZDy_S7}5I~)r(UTMeZ<@NYPeg!+n=eF%12xv)VjImk)tm)#}qT z@>Dn>W1g%}vvDwLwV$}@V#A^$KV42N4Cqc_+gU5Pz$fbv*NKoc&Yk)?guyV~7d#yF zYBlI?Q)`rO{cJntpUHm!2h`~86I?YyO!mA(! z$Qeua?8{LM+Prv=3SzmU?$XpH4mrW_<~li(DeU#qstrD~Z%|s~qaMEF6;CDWa_1+a zk4Fda9sPRVa;6udXcShY8|OEC+AgP0?&5+oeWHuGkoQ41hr|4~AD&|sea`q5gUzdoi%20wiS@z z3AROfp2FozfsM>*&noy@gspk`dF5L9b|w___-Lh!Q#}P8;!#$DHp_ZxPUXNpvFV(4 z&27a(Z55w9)k%`^rRVlFAgsI!V^X4A?QTHRrMk9XuV%r-W}Crzo57?kZ;)s*vz_G- z+_)d6|Fe3ak<8Q<#=0mE%+YK6G#wdDEypw0??JPLWy3YiEPILG?X5c#tZb%yxBEEu zn>bl)Z2<#4CzxFQ2-v*~XHi!36SxxRrHKa_h90Y?uLH#@NrkBAcpbm57ph%)5SZe` zRoX2%@`PBYPUJIrN9x`Fr7HHyJZs++dB}7CNOf_*_RYcI?LuMe(7uInS(W2=9-8vW zeRwM_!ODn{LS&B1J-#0lIZ2Xox4+38pSt?3DcpeEP|o zO)z=?Z1b6uO6d;gwz0&Qcd9RIc&RtaHAY{ao#KKFN`Rcx4k6C1lxIw#Qc~>ns#1@) zNZydpw)e3onl5p;2i43ze48hT51NT_yEuUy)PMLbuWaP!87$Pl8kbfM_meo1F&U2@x`wjb9$Zil?k6)?W9$)icrDP)!^$lk1WNn(pdK;kZVT!zXTf$#g`-rcyK(Jr#w;~O;6pVBtWjO1As|~qB@&m;{ zX%kb>-D`yG!}>e|c3pR`uz|(}&F;pRH(z{nP^sa~og8IMGx#*oBtES!;zjt1;TbnO zGkW9`0%fr&Rg6)@*I%Yi8&Wm~=N6B5weCaG(IWS|F{1+WYztS8Xj2j3n7v7s_cjA=hRzGl?lOb0phQwV*H6>+n>yC* zK4sutI#)Y-X+Q#bgAxQf8PgK_lPbiZ+XI32a(%b2BOp(z3m`t4_@Hi3!+IdNsu zbF!eee5~+yeKbVub#3CQ$ym0w(}X@eFSCYrxa)d>;P<$@hcxA``piD63H`Kn`J(i- zu3uGh95^TpNxnX-UTGNa5lXpkEAZ8?7deX>!h6cK&^dOUpZ^0Tmu;^fA006h5a~XZ zoyYi^^%vSR$h+efo&cP|PXHk9`mW&aphD12WEty>g@4RB*^w^Zez%$!rT8eAlT#og8x)ySDGDxjS+!_Hbh&4O27) z;S~&dRnH)h!@j#U0$i8eTK5ypR^F;P1vK2d%WE*kOqvxdb~~ar8<+hbK>H^~F^BpY z=dNY*i1@UxeKG#vp+jZ0o%*)vL7^{$CNs`2ubt;s2q7v4i>sjETYtU7`EOt-Lr#XA^1Qfm(g#2W8x zwal$by{CGVc<)SK?W232EH4zvkL#?_w^hmmcTB4w7V|*XYL~eMj6qvntDl*qHX6pH z{oK%~l~HQt3ggd*hBsUTpJq)6SZi{seXQxK%V|;Ys?;p5J-Gh^_rO`!f0<6Md=2uE z23vL9B-}P#Y;&cG7r^4uTmpHAMW;^>kaje6D65erESlz2l@(n9tc}z%DPx(WU`!Ee zJ~Y4t(G&+ejf|1$uckA`A0-Nouh6ruB2y!x&Q!WH(qnYHmOLB|Q&q!?Q*maZpGe{RaG% z!W$fOt^)({IN=z##T0>FutyE}J+BR3CJiNYhb`&z^GnH9;I08NsI|TrDDd^M!2DL4 zhTt+C(q;`ZS)*U-2WL5Un$O)<@q7nIT=|ju46YHt8-kj%-SN2)fC`$uq-;@=wlfBt zYeM=))5!DcF!2Iq9)~o(@5HWG5Ra?lhg0GGW?BtsIKYWOZ(tf_wb=3K#5IAge&b;) zuzz(glbFdUh>I*UNlAV36nT*K`!;&45?pAN%FQ*v5nBZ!p?HuEb(88ub(;4eYbd3i zI|BT92>zrn!pkUQB_0*V=Ffs?#->kIh}(HqT;Hd@3t>4Hy$^{sSAS7P#`xL{ytuGC zf;%(GB0I@>lZMgEKI#CnFwfjV&2^Nw8$q+1=flZV6#H68%3{j&w>Rh(q!gp!iC$~L zY5atj{mB?Nqgg7#HE^}-`^%DA%lAmsB1`DDzGUWK2u>8v)9lH%CZx&-T+kIIIjd4m zMcO}8tdSvOg~^1LV&I&#zun5QsvN%Ac7_BW;b2?z7N%C1F~?gZn=He>;T=q^`J3-- zMRcm#s>VQi8T~|l?A%cN(tZCSuqRx1&?6&wZ?z0Goe6SDvN+;V9nbsh4aTh-0qyI} zcO*Q0FPYOT{DK6(`=KsWv#5>7CAy4MWXZ8I%J)xrHB{Pm&S!Qs)~Y-w5C!IzI+`6^ zlttkE>k2?LSeoZNIoxpG0u0T&(+AlKWIkL`Ow_EXwpYVO+c6g@`+YYI#_l6$th(_A zwsCd-uxu!GvnRz{UW}T2oW&q_XwF-nGBV)>)fnOVE^JEGT5*+K!k_H*CnWM?0#gg; zPrjVA=WIPH0J1b+VO9m6)X#7MDUUp#)>F6S^UZ}{zBKr@(>qu zY2m=|oxpfEZ`%uy;QP#^Do1I{cQgl0f4hbFl@`%&9_a@l%p0AD`UXjr$`^XXa@-_s z24zB5zZ^%#tPgBr>p!$OFd`u?N}5#C2rQoVv2OiEl1gm7;Ac_8Pmg5gm@e*uY9j-_ zdf>JPer2FSIJydeR*X+5IXDZ-wz^-UO+r4z$xc6k0PIq)UQGM0BkHYl%!F(dEZHYe zB@*{na8^l2-7oTZP~}mA>oFY1TTaZcBOW_T#SszrT*;3F{|-njx?|ZtGv8IQeX16F z=-UV4x6$jZl6-&vI>CrM>Did%Bd-oq4vjp?7sE0` z+RQ?cn|33vG$SQ*{KMtOgz2D52?1%(oh|ypnJ1*!vwxlt&>k)9sR16O%=8Xe`l%-9 z8_y>L9d3`@Tm$>@6}FbMtIVFU17LIVKw`cN;1#-k{T@%!8G@}AEMW}li`hTlar|-} zX=3wb#XsJ7WiN%jIWxFmiJ2!#u{S7;`*VvOztYh=@$e9Kv=r0u1fz6PxXC94NV)!yU3o;*VcqSYcU2tpN?VrI^ z%k(b3gHtn?itoKqoo%f3CQ3=}ByAiI9^>Al6=l5Ctgm@~8V4UkbD@grA-(HN4bcIk zBijyP8yOW0)&Zrt-f%+OC@aTyh{n@ zdtg=%1GvyxlppAIo8TGc_(J3S_|9eO;ZcWNpvH%BMec@J?1q&jBgbFGPRT>Kpu3Jr zh(o-=-TghH2Dxk1W41N(U9SR8Chh9m{$q3JZND}y$E`}Z3N3#{WBnL{%2NpA#ac6O zg&@W8IW1=_vyjq3uk6yVs&Fy!j-w8e>PJ9a!o1rvB>M4yCM-_jxLOQb+h7``_Flr> z4n11N6vkn>e?+;fFv`DCmzhkRtoJZDBkeiBF?|^y8(tK{4$W8<(s+eyGm{3V!q;<; zAUU@~DJJ?3^_n484)6VChB|b%ajf(ESUq*a$d$+$YH5h{LGwH}So#Y3%vQIJJ_0M6 zq`2SF(OY14t?^j4jz6O1*bFt1JYDaHZC(ub(D8Sup7PB>k9c&+b2jH}yz^6^&tj-> z6odr7<{7d-MHwO33p`_+yM9xS6F*40%IWh^cUqrmZ@*2f9Sm{`T3j8*Id-)@tL13ynETN4}gcP z`E~1K@G#iqbz^gtk3)T~JA2;oZ;lz1GQ~ z0LUIOAV;V@?Yl+Cz%Ll&*4q0ElKAvLWxUS(TgD6AB>nmR@!v9DNB%A21%em;e82f` zZLrw?dpf1zU6@LTdfhzIU-?&mPgQk-Hth^X{f^#+xE$E?!pKZJgZqlH?N&1V@!2qQ z6ioidovia_gIomj090#qb|uOg+SK&!CkUVT{gW3fj$i{mTzj*@rsyiFnNZZJNJUv6 zOd$sueVmSA-OQ*130#eZ_5c8}1GRfnVg}YLk!UQswbf@i`$vJ`v>)9D`KKrj3-6Hb zzVWRxj_K{(CMAv5ph(owTO{)$IQQOojoCRO22${ke%d>~^ezo0e7Pd{P1(%*KFeMn z_qJNL#atJj&mJ40TMfPT=PS0`11Uu-d*bGD6j-d0;A(-8h>}RoF2FgD?eKjQHKzve z^*sc*&a{AkP6$e_oV(ywjiZfL1pNLKd=c1NNuXuK8oX!SW<)u3Ti)={?@uluQ*M=b zv+(-0P=C8(j^%`<#yxBvX3_Q2)s zazG(#={@A;D1;&%^1wy#!}Qw??&+!V@=Wf7Zk|9%V)rujLu8V2M4p#rVw}&J)eN5d z^1fQKCV^)F_orTtg%Sw=G)n$pj!ws_c9o49Ulq#xdvDk_71qV;ox4KJxIGeo{t{f6 zf%=qbuk4Bv@LGH<00N(fqNcm{o-NcMZtr^GI8XduW7{TJe*Qcx`~5IiJk*@1O6h~C z2LJv9nLSY)!JtQ{{PoIx>LN|8&lqHx;5@w7tdVW;x>kRy#=Z^3OqS(715pYS%U!YeBOMA8kS@BiM%%OVP;umpSN?>Trc4S@?&(fcoU|Az7YhLXEt?XuH_XULSkz0wq>u>fs=E*B)KQ23@eJD`zw4Tp0iroE{u zfwBRIbLU~Ll|r=QfhmhPayo z(t8tC%lc=c(~NNlg!8u!qLFtEohI<@Abs5ODHiO!;bl%$B61s5-fW~#LMScak|y##nH|HCE- z255z-yK^;Pc1NREm)D?(BL_O)MFbfQOe}5c43_QQ#^4BnD zETGTV3Of_*@uOFmiT{8j5tK|qB=7M_LhaFmf4}T*ODiYvkDmjy#TKKe-8S!G|B&s8 zR544I0x(XL&DL9{^AQ984Z_Kwx|7;9aD0wxYXRXNBRqZofK0A-nx1sZ zP(%<4D20(1e`u-)>Mgy_6QcEMQE{3@Bf}AU8ZY=6Sar&7v`{r#!;nr%Vx2e7G~1)I z$!>M95`b;MSR8-TWq@ro!1lDl-oljt(vHUfIJFi$+I+A>AGiPYr_XBO?-?UfMijGF z(_wjkQ&Z0F!Mg1*7;GsI6*$gAU*@R{6kwoMpWWMg+tR?NcAzDOV6>m=gxt8`|G=mO zcuDt<^t!zkqAi~UlYfQ!N7y0f!9e! P65m+=vR;LbOXz}ZprIaWoB_Ptx0VIWkfYROF9nu|wh;*k&=b=N8M(J*lM!Gu>_1iqp z`>yYA_||&QS}r$d?K6Asxo57qX71S`3UVK@F-R}~0Kk@%5K{txr+okbX@80aezUY_ z5(EB+{z*d92>@8%JpO~Y=Lx%lUlKcuYd9<0n>o7~I+_9^CiX_Al#;fF=B7%fh9({k zy{3WyV6Z1CCZgg#ySE^%MB017yF3<=KG#Z+*@*Ouse)g_-|!E<3yRY?Q$-WfD&LF5 z0+}{UO0AORKNw|yKR17Fy?2p;M_7{MZYd^Q`@A5iTG;<+njp8nI?+4yFjquN0L1TR zJMnK@KB@|SdX`$bApgAt0Geoe-Vo`+c?#RW*W&t6uy*+&ps_%E+L*a4URvu+{=bje%$-~$2b}+%8 zYIS&@cf9dDqhb3OldtV+jPMCU&8CR_8(-b(XdVB(P6O^EBn(zHyM1zNX@HZbSNryD z4Gnj1(sdh`&XfIks8g2ZLIzM9x6naR({Jtv1vlCS1%{tCM^w7q+0bj(j! z`LT@3O4-Ps)+C; z{_gFg*v(Lhcu1x*uIaKVa(Jos$6IgCxbg3_qv2x)>tie2T&(vUqy6mnUyAX)?juEM zB_E}dCo-nE6=yhokoER(QacV%9YEF1H!hJnHj@0WEQ4B+uA2AlQ>DY|LEP=^13oU>E5N0y$A#a`BN>VFr~J1)GPLEe?%H|_T5rC894nPAj|~ zEK)rnP^I{A?qD`b(viX4l}Ql{=j1vQcsGJk%Fc3mhj~Zp4(*b?VT$IIoUM6H-c5BK znW$0DueNnlJs>o_-;s=6dcwozCKM+|3`o%_qWQl1w5LLtufJk_&go!Q!Uwz6bZOmC z!#er!LiXDCyIWtkvvnW4Y)MKYQj&^j5r>_EU(+_Q+038B+W>*M*6bvtt&CkQu+t1=C8Nvf@{zC$AiOy7Ye#MPschSbhxv zU2mdPAMM7bZP%_h4xhzVm1I zg>4H(cd1lIyHd9yt|g98cLG@`X8l9 zJ+{lErFM+n9a$!(Ublr%WgM}usR4xD8Pg0GWTC?Q5uyoJ>dZoKligPGjCOx1hRPcc zinJTeXI7o#URR&v%D(c`Bk_70R+{wd{MEjiuzu#rJKLqJ-NG+Pgevh6pvU-VR5}pH zeynCZijmVY+b0?A4I$i8`gVJY!r{=9lRl7FWkIDewJWRA3_@bi;4x=K$@s37yRcf$bwQ#++^UfGKf79s149(GTRBp)Rlbr^agAmdja!vx z>&)QMdZXRhB;KK99OOZlIHMnGlENv;S;EmJt}h`@>{yVEB^%bffLQg@%Jxu+XSfis zyxG6)*3}$RE(V#(NXzf;U9Kx$uB#q3o$2IH!X@Q(Ux-r)0}%-*%}O#$R1Jl87e(Y& zxS57`d3U@!yxg*>#8L;y{(HnpFy!3pEZ!zq!ox47eh(`GQ_#wx&>G95*CP2ki~}QI zh$hOZd*Tm1&gi0Z4iF0r2vkODxTwv%kDToIwMAM)FM6O7pJll^HmsGM0;;wmKy*5O z{I2Fsv%V^&;6BM39T>TVNTm9|Qs;@Qh&Acs9Gs^p@Peoj2O`d29MjRhkqwAvDSO!b zMKOlL4O2FrY(!k?zbgK)LI9X>zKuXdykUXtUf(aIX54F}P%zHyd+fBo&G-ho1CaU- zk{N*YK=dsaHKYABZk1E^53aGoym?^X+LH$^*H^$`MmL1|?|LWf1<2(+9{UG*FyL2sZmtdSvUI)_x?qbYJ8B>?N;&(H3%k2S= zn?&Iya~eO0xMJ$ePR6gFpoWQxXYj)Znu?95rgvx!&2lw?Ox4v1H!UQ!iyz+%+DA}#$ugXEC~Jt5zpTY>wT?JRm=T9OVjUo8!zWLBF0J8 z*zIpqa4?ehYV+3?WSj$O}ULZ1!12s9cYL5I?cXvA-mPbS4)`V*dK zQ4fGN>MmK>F|#%>@K~z0403W3Wp)goyz;Pmq{r4|UsQN7CoVM1Vpe>sV1vrXo9Csr zi7J&w#48cEo5XqFVEK8L@{4JT z***>z>Saf!#2Bf1t)>yTk&;Q;fq;MQS&mR~I4WRG(RdNAZOE|1LNKJ6=N_}uDm$OA z4enu^)f1}G*G*g_=HiP5;^}4i%W4EE5@V+y$cynW=Pc*_;Qas#v^1Q1ED6bfGD?3p zDl5@H$geJ+O}^;U5ZuRRwki5i6$0!dWrqH!1XL?ECPEKZRVR8Ov?3}|yrxnc>?bwy zO{O=(+P#oAsm?GnID30QF2-g~U%zt6w6k8}3vCX`_|KD#?km5h#DE!Wv3$%4Da|U@ zajRz^Q?q|v;YoV7_lZ3n-1R|eXYx+}pbZw9sTDaa=n8k8MJ;b7yk z`txgtXBtUw&&#vTTROuqUZ~YYjxLGq4wJU=haSSPsh(@F>id1_6H=Jdlh-fmj9-7! zw;p+ZsMK{oblrQ*GZ^E86>9yi#14)jg14#|Zi#RaY?vT}NsEnbZ*moR(fdN$l(4uV zO*&HiVTW-pVyXOPJ5$E+-wpRKa7_(d$G;>ix+oJE%=7&SS37>WbnP{CaDUlrM@R1J za5iSn9IEjgqHzf8K;o7q2wbJm=Fpt)|Joa z-`p>RX75I@N{AWLt7Bs?PoxqskDE}w50sVG(MlU)yDu~mp97(87QQ-~wE<{t_-K7m zc!dcN@}#5c-M@pfiD~Z@?0jS@_h$tuLj&9nS9VBEmBDOg%;VOFwdh2K?G;CiG2~?% z?2Du9%L4uBz~^|19vzumyW-~}2_+Ue+iEiJwA&#pc*DArroi%glQSAy<{v&5!i0{M zyrG04^gHQB<%+*j+txm}iWCpid_Qa^yA4?-p_d_joaY=RQNBJMMhAX7tD-J()F^HJ zXjBV3w0w%OShlB?Eq=kNS?3&u>I*8OT*U9ccisue#d!g?c$22AcqU~vl2kz&uk#DwH zwx(0FRXHp)7G%D{1wKfjfKe)RVj1>jxw)v%31Vgw1(mA0d1Oz#Exq!6@7lL2 z;85fvCw6Fw@S-I;mY9aBf1Mv_P`IwxDF9=gL3_!lY> z$E;6HHTkg|QO|-0V?cg^IT0^bC39*tl1TI=nVJ?-dM+2l3|x76lMahY=CUtcnrS>z zSbQ^Knr|#1XwEX?n%q}3k9jW4CT=e!d?C&Zhujuc!DKa=Wq9b&Bx4~Q=oYP(K_zAR zK<205^U5sZWy_*J9yTI5fB5n|Tf_HlX!=IpR(6hYiI^4grM_mo1~D2+5#E91VpC`G zITcqk+3o`tiRwi<8V7n0O{8xArT`Gn-=#``Y_Qr-u zY8`g~7UXo*O=Oj&Jp;NncJ_No>K-hc%86&^E+n9V8YGkJr>J0IPu>s~{wv_IWHafY zv_R^G!sa?p@5)W%pFi^h*{aPGx3{y`^J}mx#73 zh-V)!wSz-^rm0~4AiXR6mvKSUU&4&$(n;waYeG!$a5L+{5h4ON?Pad1oUE9A( zzemnBCDeGwFmv(?^G&Ja1xkeoFCAR}e2$(_-kJIHJ8l2j&aX98%*;~If5uvoD4jlb z)gSRnLfPX)QZrh-dnu?Czvf?SQ84zH6t?8AB8<(e=4K>ctzefTae1>-$+5`W^z+E= z>&Oc4M($gDp03oslgSOqNfOeX?)=u7W`E8tFpe)BCPFKgxC(EO^d?rV!!;lz_OsLN z2NQ>Pm&*cfaH1Kf?l_Aqq^8M_Mib3nr@MiJb#@x}XzQ{g?fC$0EUWSSS+)md2$V~l zIaxelKtg$~w57{DZb43GQ^cofoXY>V#Eao8i*b$c7M(7mGoPJSbDg_tq`@=(OC>r_ zgyks0<7-hsdtSaqAt#50VVPZL#xZON0ucsZm^ymXYJSGx#n^Pf5JFs zWLTL^JxUZi8@5xJN2bU-R}#TXoM?Jcyh1AFWam;Gy2c+GPpe9S5o*FheY;em(^Uy{ zrifU=@>O0bB(3bxVo+a)fUPubH$jn>tiOS94V9I-4k;$(G;k z;``+Xn~$FE6`*dkCL-c`tA5TlG+q5NS#jh?1y#?wX`WgMg zLQ1ZgeP`t-%cX>nDM5=U4U^9Zk1{1@VQV9!0p+n)kL1W-FZ0nkkBj=+SF?SFl)R3s zh`0*lvwVl#S3VlagbJ6+{*`*Tu*AJyKoPoIyWT7Cfd{7spmwH=F&2pDF-f-X(rJEG zja;X0K8>%WT}CxtAetkyLU$hVyzB*#7KTVnaVcAW3WmdRu$L2L}g(CaVSL%&Q8>&meGN^N5_73T5(A3zTyh;&sE~{LiuwagO%OuOU-%X zjGrAc`Mn%3yoNC9h4l)Es4dNeT6vnQ6ht`622bT)p8HHNUCoN$rW{<8XO!FfNU`fj z2vheHNqg!cu|&n0cX6y2W-z=eJ<2|m9w_wzIcBN@Sej4$6Ww%%j$qAm&+K=6(MEx; zuWQhRK+!1g`jE~`%9PW{YbzZGFc0Il-@DAS5q>4)rF;?iEc*e3WutdAwbt*L?#QJp z3p1YiY;=n&sg}2xvvPON9%_d`J-TyC1%m(9xHsle2iKl?u@{0JT0#6b#^mcN(($6% z1MgdS+NC-TlhsSKZ3b`Nmx~dr^{Pn=T2{@4gw(!y1yPg8mYG4u!JMcLLEXthN9Z4X z7afLs-FOn_Z8j!lFL4qEk~bH89pzzW;j{0$p+m6wRBkD%GqBDm88u(R#ZsWF%4bQQ z+ec~R-F?@-6e?iFOHOjwRo$OBg&IR&U2!Py?M9v1?kzOIVZlCZf0gvsSlm}eK`Qni z!}+_DQX#}S&cg&r8wT25PG85JsL}g-X`kE11A_yJ*lW`fKTV3`5`>#`o3ggUsV+(W zAz?KiUf=)ol_3)e=oPWyR?PR{_VHpA~dEXR7E(u-qHZ(iG!&No)Etk1M?&pLvD=g8i z*fIa=LULI`I&aUmRZ8@%tCDy}5H|KP^`GT=8_R6(TfJ6945hMPIn_OuD&$2N;hOF2 z<|_^Tv<8_F&SpfxgZcuss4qeJ2Gwg~|F;otZ|YA$CTo6~VJu|w(T{k8oO6(yjYaXR z>CFV+k3F%3=d!<>c5cj67EFfpEU^uKBVTN>Gw5Fz&!Iz+F(*#3_3T-c{KlvzeP@Hy zf{hhwh6PxT>-2F=xVqmP#e7N8_mR>54Gex$jlrsqTbbmt{94d?C7$pRm~F%4Iy&)n z!l&4>Mo*R=kajb_%b@A2knwYtqg$L`6<4Ck)&r4FBf2369~ldCqBWEyk-DeJ8z99L z9=!~)Rgls@A|B$TXJAGXZ^Zi9ffLj7TEvX6(n3LUv2j<12F=2xDV26mEHCA6x@5?8 zcXN?(ia9MHOMZI6G^uQ;c(-0X@e$38C;8kjp1vL0@#WHP)7fZw=zVUeSo4nA>zB(k zf#>vhJEe&ktdGhD}N`-f)ykG9P52 z^r-#s#j9j|&Rm}Qza&v3EEJ`---(!I5@{QtG@l@Jg9V?7P@hYq%ojI>392!d#xWaH=o4m0fC1&gbg~IpBTmG&F znE9>73g!3i`d1qNhT}8Mn<~;Z@E!b$t6q(_tw3vN?IOmrVP`Z#D!66`Re5B9Ea~Zi zHM&OXRpd-Qz8UzSgjnkKzFdmU z48GTW=*o5{7du&;e*fKo(>4gLhRIbH&n#aXYADSIkhHyRuc*5JRMLL&6J=nn*CAGn zSWhYz%UXHKtz8Nbvz>OoRX&Lq7pk3P)MQF> z8Y?pM9_HRVhuG&f*P-Ioy!Dl>GYMT=WnxkC!5;=RR+D+U5V3C!U3~tzzdn;^fymE# zTcuGeeocy|ZlBFFA-0c&;9yS~Lyl~-3Mj(Rv@MUsPSJi=!WN4AW`r_(O64#X9W5Is zV;$ZLtSHXsyoi#T54GHS=C9UWt~fgjCB%?o6A_lVdj@Fy%z@9poG{S6TfSPL49 zPH_{3Yw8YGK3pGsC@x$u{2MX!GM{A_jkc-Ll@k4Z26u$>g<~{z9INSCYP0%CX|og= zO_pikuQq7dw9xNO@Yrf1X?9fPyf^;Kc3jdhv-1K_%Rd4ivEZ}po7X@knD_AaF|eE@ zVzV5m`+-X$71Nf{laDq(*G}rJ30-k(n4z+UL)jE zsogGO#$l6Yq|rNRx|qj80WlyjUUV1PmymAL5??yh;$$A|EM2vBQ6|1ETD>?lywl6e z9rC)%_i_K)LQR}XA8QI$-FA2FJX*-16eZo1|8xxt?%u>qs?qJ^PqWadb*O26Pf1f_ zuEoJ9vr!rQl64iQF1Ry&Y@zaSg%gXjy>jTp-Dj_%zT!F1wi97 zE>q$AaYW0K5^h^z73!>$n0UH_k52wDMR)clbX-fkuAwM^IGB52a#nJ+-7lKHz3Co2 zNf_cn#>R0UW%xURuTW$7`3Is!d}j*BuG?~X;$l-{8y(649K7V(N}!H27Yq%Wx-w+w zh1$QlpW1xqc>X#{NcFB#)vx>wdRfidwMGS*3Qt%PP*K8RA3~s#RphhO&xPXx=O!m0 z&|@v=X56ei_--xX)aKBUntwj9?s?Qnug%60CI4f*SAlcHC=oFx$vMAsXy;>&+9&T>pQ=ZX~!!k?0guVz0!?9`{5VOV7#57~Cj*!t<8j zqzE~ANWwKnvn?beb+UH2QyLra!)T4{ceGapE9C*i$e}gbY_1c3NVo#tfX2{A zs9_>?+~IO9b}tk>caZ4yq>0Z+%vDztY$)lIOeD^QE5OnnN^-?ZWqbVqX2St(CDh4a zv4l`gH)QPn%L~li(=Nfz0>qWP&q4V(*}CTU&0?dy*fL|4loE(dyxK+U0U>Q7@E(;j9rQ#S0sQ_oF1#v@1s`)|6^y z>JQXpwp_~IB9Uu``5MPxTmatZA+j$dJk>vs#-$U!P|hjbO_5-%74zA zjIXCs8Mx)wh&uE@zj>ptinCV`0y)5yj82g~z*Ka2ICu1x4z6Drt+>C+@mGHJYc|@2 zc^B+W@6r5~$NUZ$4BSAcT`2zWey%-pGd{@A=-rmtq-g|CF?Ra388`~H8L*CUABbLp z>;Byq$LIjt%`0tZW)A%AIX83A0o2vye=bBxq02=roR2$qrS6UX&y%9BEf0zrk!QNA zh=gy!!?9;N4uBr-Mud5<*}uUNH@cCkdpl~i8a_TjIP89|X^m}~t2S5WzSrVkd2<4q-;eB#rkR{uT^<;s zy7~eIah!v&E<}+N=Gtgw$f5JWB&dmiVK4H0p$4_c#x)vo#+-P=((>!(ftxAhB%;tQ z?ameT1J$axV}H>BvqZ+&W|k)6XU$C?XpJv-zuwQSbiPA}4wH6c6}#G(|9rMECmciH zZd)oP{Zjpp*)*r@{h=uQG+5P)3FT2Xds>^LVw#&7Q%(UzL!1{d7sGlaK z(xkj)c=fj-CauMTf3yC&uIz^N&ek8Fz)T{TP~F+#K1xc~ly^*8Kik|%gy!mKVc!jQ z{^w#W@pscsi%N2;#K49Rs~!F#TKe}jF4tC@o_DW$`6-%y)g5WLloFePkMWvu48f%! zt2GF4B{lK3;}EfbFis~~epY&!-5rk5>E80@m(!SyHQ(WdKvfc0lIWvZ zk~7bdFX#zUcC}fA)B73u*mHc(&x|5bQr3HZQaG6Y2{5#>y>WRgvvpZz68#x+T#R!#cbdo~*oK;qzI=GIdHl5)3p^iO z*cxwTXd*@&pg<1~yWX|He^yE?Zsl9OWlpLOn@M-_vu_2tPT0`4Ng?skGN3gT)9cc9 z&ux0%E*LmRWU;t-A{H=Ug}^C?3YLH7@zQ_^_8nvAt24609FCb03i{A@%@HbDcz|z= z9tMMt-cDHQHwLlth7?hq#5q+Hd~1zCBD1t(BD0wLMLqDuAr^&R3#@iemVx22uN4GT zY@_m*Bf&dn21v^iJP%}OQzqW6Er?E!7w+9pNWaP`)8q;55*>#^E^Iwd( zQn0*x)=B;6LnKGfq^Zd9mUhDC+R4WLNfV6Yn0dO7$-aXNI0$#~Qf+avtLEn_cyD;O zpz-E_?JDX{;h`R^?xtO3KdQF$-iM9-xQAuS)SGm@ALIKWwmD5YF%k>0&fh#&{raxv zgK)bOBA)i6!D}VeV$8eel!5Mq!`(PtY5;!b+4V8zqz6MyE2-0%AM0{dsLSTkDveNS zgX`<}!$neLfz)DPH)j+riELAnKn*-FRVv@?|)P z%RJF;$6=2Fsta0gVZs#es@C8oBC>`=2c9Wu zZ6~M<4E`3rX-bkm!+9f_zu7qMjtiJmD&{jkc0S{^zHDrpyUQ{1!5Y_U@8;TQI`8uJ zkp}3RxxHevDd<=dkrWb>lV<6!Zd-S!+XH_cFAf90O3abkn7L}{??5{LP6kbNWJ$#KS`NETnrIZ&RHP|Cc)qdiMvJ$Mp1BsI zFz+@+rMC(v^_jWM6CSn&8-kIm#F3x_UsqEh13+vXsqE~Ej|!d0Kvd-qkoX9I;v zxMq|Bpr3F|pe%bKvye5p72nqClKiR~f%C@C)k-g>w%^qe)#?WX9`8Ju=%Wj)b(u zi>2J^y2;VK83cySM9`;r{G%ji(`t2665rsgr+3c`BXv1htU7C-bpY&vcX z@}*|B;NnK#Y%D?$7ssBj9qn$U0ZM#wjG?d#5jlwmY)Pn1>Yb+N9RG z_`LF)m}->R3V7EXS6cAd{PX%47*u7Dd)h7*fA985<7KF*qhg$?;R)^>{v-*3UPFnW zVaDSQw}73H6wsVdXFd@!t(;_RPJs!oe86}yT*M=&(x9syQKqxziXHj zBPnXegNqN=JQ_?=iH3umqVetrj6soBAJ??)5@U8xmNoFCVU^qQKjN~aLvEb5Jy~fK ziWre$qPe9aOD`9(f*A#oQDf;(f9*c9t2nwj*58g-dcUpEPTx5oKM(GB9m?LfCa9O! z`83m}HXb>XL2Aiy+Zt|wt`M$5pp-qZ+V#koXyxC?0xjAjMtm$>*t&8Sd3v;)hI2F| zEYtxdq5A4Um(c|@G&6a~+WGcj>WL)%n zMu-syIvNOpYDk-ZCef%%=S4wG2VLj`!;2Kqxlr6p{1|FwNAAysaL={3fK(jfAa)^x zR+08H5(f8#FbAX3Y1jpqY5UoHd)bz{s_+aiY)SR!pHs&?841Yveo`5f?+3U8Bk)R_ zj{Up;WH11Odjv%u;aj(o42D@a7lc`U(-~LfOiQ|LZ0+Q&t8;^3oCq0|)t_#yrmzHo z6peKr0%!z|F%@~AR6SM5=6ZSj0qkLe^xSuxcINnkpLt2nr)dgnMj@zJlUV_IC7M^v zW5stci>g2IkX&I#>6t&l+@b*38d2vBf2sWhw4Q>f>`{{gh&2OXAM{NrApMZuD-*oF zy*|5*qy-*zmgqGd9kT|WZZ*Wing&~%-Hw{A9@HX9&pn#t5?TR^!Wy3=FvdP@d7$DW zu)bZjX3>~I8*VUb_4@jQXo1Z2hLp5m4Eqs1KI%Sib;s1i-gRG9A4oDuh&50mVN-tw zX2%DuD8IpWNM}gyg%o$G=7NAaqarOEZ9BxGk*q4Cd{=JzZyt(+MZ^czH@2~h_f37v zDtqY9)-IS6>5)eaz%Ug!@16_zPRaP|RRf;?d+g#WJQ{sLp*VWu=g$CH;`vJ`A3;bl z<+Bh`g)R5D2`x{EOdZL={(S1(VYn31#~_~ewtg#E*0BE=9?hnU{W=GT6CAIf!M?k2 z@5MdQWTlDj^YnW;4rpW-=2j^lUF%N{&%LRGD5RbR3@JzttS^eCEY(QA zZ2|Gpco2YvfxpN3p9dfKkWRk*z)BarqBU1%qO3^krwuBu_4y`Tb;qI%;YE6U?elNY zpr0mFCFabqKX1lIR@?U>5QG`DC1Av?nM^MGz&S!k%A%BvFtCh*n56hjiZNrL6+#2Q*?955^Rq)|h#Y|fZjJR3 zHej3B{%0(x|LMv|s$^!jlXJ5t>zn8qPEA!}f3%rJfi3lY`3~=pzq6l~ zEF3%`P*fsoBtVNbSjJ-5*rJY%Fo3#mnXf^Qu@9(!7YEu1#wnIiF2$YFB4rNyd@Q`d z9IRzzr%&N!2BKWI1|BeS^4a$pvw~>jdPQqF?-z%CPlnG}x1YdNF|?-AP(}+gCNqWF z`ILnru01!vIE$T<8^VZs80Plg6PAka6$3f-7zR+I!MfhWq9E$^# zOeo!fEcfK)c2-H8Nre#shH1a~oOM6=f1vm2&&vsi|AY+S-KduNr2kWX+z>$x5X1I8 z`O?0;i_MyBAWjQJ@tnOU^_v7kQ zNzmE-X}8QiJ;PPcT5v1`UO$TotfU2?v7Oa?s+@qq`LSEUy}R}&ypD@MkNPUiA5K$% zhk#)^0}Vgxz%_%|1OQFnFrJTKtzr%Ro|10E0q2+NRpPfBQh{P{xzCL!azISW7_G)% zF8Z}pPTkA=$6)FKY(fDeDBvOu*Puu%&lMz_kbPZB(E<0b?TEi1LCm3QOPK@cKU1d! z2?>0tz8?l5oHB>dzjkf}r7hKk8^ZNR0uM+b1{aA7K-3=X!ie9(_Ma**Gdh{#ST1(J z2y5dSJjyWvVZVL?1E_E{mIX>_4RL^FUohQhl#bQuARgVzl@zFtqpntPQjJ3g4FV9h z0pu1iG7?Kvi!VH>z^_hTh<{3+_;~D1-1PZh*GIPxxr{X-ED%1le|(vvq*1gmoCjd% zo02qXl&~gl+2R6n$ zr20qjRRcwCd-;$FZSqLPTef0?Gu+m0dFYvw$P;C10N@_YmF7Ol|$a&Q`*y-@fq7$IRGMF5?whpVeq&Nx7} zwDQwk(ybXd^7)^SLFbrSTb@1Ngc}`@(&a7*?LXbVU2{K+6^4O33jl3MdA;nQ(o+9} zf`;BZZk-*{d>SnhJWxpntU+87S@{wR7qHMof zEVg6pZ5kty9xczHSkglTgG1}!BU0Dig9F#m)heHl3PDoZhzE`gdv(;df+5j=a(=sZ zW%I)CaWW5FqvWCmC>-jhLGj3HlE9V4%mF>UAZ5zDzbc_g+-T4;h+Vgztv(06_EB_u zt}p=LJSuC;zw~k*WvTeP*lYhZqnnERztgzVKciWa@~Qi3mcG(H-VOj3L7+47>#V(} zYva!=J^OH8S;U>VGoGz41x^w_-q7ag44PT_G5Fwnr61Ua)C-Y$v^c@za0kDv%1Z6U3Ivy26%5< zKNt%&vw5H%?jvACFpE+5HZfk^3a4d|k@_K*f<*bbq+dZ$_nGQ8xb4T#dxH=taq^z4 zrxpa5lA7YJNp2nmt*Mc}-yEoSSE>IAPe&cT4R~~%8r~f%N|%UHYoxR6;ylRSAOX87 zZq6TEHkCvHVEnEb0#Neg(S@8f=OWc;&I;M9P<+=ireWRL>@rC4`y2}Oa0lZVX!F-) zGVq>zx}^JIIWe6Up!Y3#3nHM#9SK`sJ)S>d_py7Cxen+K3B`!Nniu*Y;lo+{h3s$U zoCpcW^p^&s0QMKmI#U$Es5~OnF_#PIu&`D4jtWH|&?Sn!>`JmRQXc0oI~95%v-?tb zus5Kk4bnz<-z2?Qo-yjri+!p7q*e!M)(XT3qpMA+*a1fI*a0a$7&w|^o>hPjSk!A| z+Ih{q4}p%@@yX1)=zf^*1|o6<1z=eqr!<=TR)@;-yAzy^j1hoX7~V$K-&Z5tbGX%Z zq7d~CweVIMabh47-BBa6b{)OK4&1Z|_9^gH5mV`~63{+mAoU>ZI!xY-3Z;!TQQPeY z1$|2J%L2jkDjNyhz;F=E-pdO@93Pt;{c9?$y0ZTD*gM8KAYxa*OF03;zkY}6JadtD z77+;ZPX|=qu5$m?jB?c^t$Y}LwN|D-(x@v8CT!}x2g9X+p z(>uvrwq9I|uo~4xP17eyF5jrZW_V#sr;oz{$A4=y3B3rX zU+vm0RFXEPjYw%W?GxJnc6W**TxxxHjM5{cjyLj2JzZPSpaeaghrC!A4cv%J#AB!@ zcIvB<7Bc zjIt~Ds22VzUGJU{3PQ}nZkpkH;?u>RIzDT1010_<REBwOYKZR4xB zP@qCDy>rBFnmA2#+O)&Txs(-5;??F=p-`Zjn9%p$rw$k~d1RW4!O8ms-Rqa%wDdZv0UWGZzF7nEU@Q<#fH21Mn{F-&iRIgPf$GfM zYN_&StjQnyGHErLP~;IaKAE<@55caa>ibJ$6UqK>`wbm_BSE4kIr(yI9U+NSwyO>r zpYX&?r#^!oZ4vIP>zAgwpK)ki{eiX@d^yi!8^mHQW_~Gq&Mogt8Cp?mP?cbH4n^(m zEU;>vOV3z&8l6H$yV?qJ8hH|s!O(w0-70Mm)*s_@Teui}HJy^QK6OVFvYR7Ckm3?W z=P6st4;Dmhq~I7>ofAK?H3pzXpTtw)RfPQPo9Hes9;6GU=D20>^C5Y(@<|4xCJ@zq z^Ft~jz-`UO=+IU~2Z4Uhnf)^+%~d{PkXx^q ziI#~!R)=`cgHzdQd7bzy|M`4J4g;%1GzI^fAKfvDfQ60?xf-!bnSFg1-GA2ltOJ~j zNn}KWN&Q@3eLrvWb&JbXDgWoDRRC{|8ScfWrLyiz{zz{phR@)%eQo>G znf7hDE)!_>Rvji}2Lo4dZGsjwy=^Btrk|dbD;1DE51v|&=$H6DF+6CO&@GmTYD!sW zehii@zv>=HPK9boku2nN4yGw`KuBunY_bAn1%TzAO>U640iP>a^iA@{oo>~~k7#K! zJKG<=`X=gbSGL%bhq7b->Fp^R$g@^R+U~&i=ty|WkUn zTy0JweOy5ct$*kDJy)<*lAq|GM*>zx_Eg^&)*tIF>b+6gW|^V(DQTG8umK15vM;hM z=a;^g9;Mt9RDiUE|03NfxTwG-{g4anBY(Y#XQ{rCC z@=D|}Z(~<5Up%*0_ArFWFxl9kRY=rc02=y!0(x>0`-dtqmS5SxrQX%vud2rPCZYOh zG=ZwEOJO$D$6sAAf4%6++Le(j*bH3}(oMlk!Yw>WO7ZaMlmfQA459iv(mc*!-N}^_y4?5}A{KpDoj}m>=Y0 zz`@M*dnX*q7p3m!=?D8e++3+n6gw2mrEM#;s*hhIj4538jar z%U(XWz96j}_-6vT>0k#hvz$I&Ti@E*Mz%Re%{YDB>9;S<>HQ&_x%ZeAxIR9w!R(8r z&B85bqq-y@%r|6KLaEpVymAk)O)~hY6moc&k`^gO{Maw)dAM&XTkZP0{$=HylRqh@ zA023G_-j?tRJX4QmM^wW;K=6~){#+gDxhW;-|NvN^}`cJru7mW$W)qj-7a;9 zfIYPA;S5=ZtF%R2RPZg7J1g+v!%3dr&n2Ntjsjqjd%bIobo4HOki^uPm9Plhtb&OB zW@Lw_!yk{4G|GIzrrbYAB%s2oR?8|^7^G=8_37YXF|y0@3or(}gH1~R?EChpJjz zPtvS!*!GZK<&4h zbOin`v>Bv!dw5$keewUme%UQJdqIDC0nYc%hC}(2TKdsfMo2ov0^v}i9M<4IOofX0 zZ>i&)7yLAs$Us}w%^0|#^uJ$SisL!JJ|5Htbl-P_fK!|a+HS)5pYacj6zJbX5!})0 zNZUQ@U$Q$-xeIvoJGlTL(a!5|xuR-PY1|te-HnujE=lnjhCtW1tdWuzOzQCLgL;jE zxce$-yqT`n81Az zw|V!_eckKez&0Zp&(M7j4sK3jv2)t%igJ1nZs{ye1p22-W9%#9Jnug5PPoTrR?!zm z?b)hN9bdb50|x{EW@*pQOtoho#oQ!sHs0lg4pel+CDAJd=iGu+CGdIJdZuj;+HWm?G-;(^OBsZ!i(4y&;2~|xE z-qtr0pxGwQne3BlXZZ6@`1?L9kru8#C9cm^C?VU#eta3eLcy(8E1U0LP?&GAD9WCO z2Mop&EG~$k3^%bw;)G(qtu`_T+AKlvN@?hBno%?yS|aMRSeybG?PrNN%&Pg5 zCW7r*1MY-)4}5Y6=~xJ`oaeJnM30ow866R&;($9Pj!VuDwhug2aose>mKa456ttl5#RS;6_F7#_Z* z0tG2?`gBm~8fc@9Q1AsWhfvX5SNP2>=6g!oIo`qfxgyGgd{CEmPfzrHN*AieGbj?Ygyd z@rzp?jQb%oWg|!~PuQx)!2{Z#Zs%=A{RCgtzV)fq+alLx5CVo-J2#Boz85YF6qWn9 z@dW)wLpI6V+qwlTb`Zc|>fT|%IEt~6i%W7q^78yXW47x<9u?)Bih5~uukCT&B)QVLvUuHF zS>J394xzjAfCPmL8I?q>?~h1vE)RLDxx3Z(bC~fm#_6Bm@IJBPnAJ8XPFdGBs#ZF_ zi|Sxj7W~P&*`gC)0H{6-GcL|^ci79BvWS-lT~@mYS9q9G5iQU-G(NU{(F)xn z&k#I$)yqa^BdX|f+Hd8sD2*y3)JzYr`%L=FO~XL2&%yB8`tFXR@sENG^RAi)UAK>7 z;w!+PxciEOur_^)?3n8K(8X_2+MW$O2QL}~-%-dUrWcuYv@E2_{OKrnK$gGHXmH>~ z=hKyW?sx9P6@NzgY;Zf_{smh?x}oTj7iFM^tJ=EmXV+yZumK^~%8Lm1Q*|}7h%G-K z1%aL1GBha;oUS;eYkXy2r5uoUNff-7S6AO^;^|r0y4$9@K^!>MIQ+A4gVNBRGau@Vyp*9N#JA{DtxIrB)7y5&20}14U~NbZrHk zc_}l5*FrSmhllf^e5JKpwaZ3N%Bodd&fT`3_wLM+08TGgIPQ(V&8b~m03Z~QMU)@OH*bb|GRUC^ptBBB!+&|MD$_*(cWX>g~ zNt<ZE|b7vfJ2;}T<~t+Oh{10VRZh@UlCf%- ztwLr(-He{%q?%HAV&)LpjaI=nLwDu+CtPUhbX`#6CpKAiS58j&&CJrfu&iWX)o>O^ zzse8XV6)`lA_zxW-(A_7>w`+njFjrR=A&e>UY9Bk(2?OuEVM|2+&#Cq7vlz~b~pz5 zY!w-V=$xyCa&HXHe2ezBlfva4k_mDCWx3<`s_?ZsxW>ajLaw{OSTd0!5`X~gUc$V! zTA|mSQ3*eYQr{D#5P+^>yeQ);u$Z1RsboqmsUKa01n>J2b)K1bXyw#jA@r8;YO~XT z7gvgAKu~@taQYXI%BvMUO9zkSV+J@a@^yFRG(%)U1^bcs;{ud~f2sS4{fw;xH&STw zqdY;%WGsEV@}jJ(fX^T-EP`>SBDzrovgw`+N4BIC&{&~V`!m3aub@gRbtuKZVaJ?G zo3lATtR{AMVH4;%MF*Poe9S9XScNg*ZxmEW7bY|AQ^4Hd6vV=bmd_P8wipvq@I@?mAEE#V13 z-+)8Lwj`c?MOr%g6yC%2MU2>t0Pp{`bLIa~f8T$s5n8CJA){=SoebWxCJ`n?jC~!% zAkkvW8igqPmKpolw;GHrEll9*=oEUgzHXI_G)L zx%a%zJxqM9Qtwp#u`OzcRf&NQbLy`*o_SFCrbZ&ZTyMnsF%h@!{A%lMXi7d?VOldj z-M_djU_4h$9?n*@wS1H%&zcZo{2e!iwCo|;y$Wj_U~8~MgKrEN5` zb6r1$nbJl`U6F28J&_W{(BHnGRr zmx>ChB}$4kPy~$LzDM&yR8scG=Z@Wq-3AdW^eB^URv@Irww5ON1IBAIQ|T!$8Kh~K zaZ$^xX|MUX0KHD#NVdKuCun*E;bdb5j%Ewl{?_o|6Eimo)iCQJF^&FSa zkIT%n9=#jefsf^8X|8*(>2Lb=s>Z_~nw2_osyOBBILi9*lY8iTb1KFF|-mZwB}l94?^O9K6kLn>!b>n2G;p$Aaro z4Gz|C`CB1Ra@9*CgYS5!id0Sga+Jiou6uSS>L@3?liU1Dfborqh>*x_!Slm6lq4~x z&d;*Rf^#Gm`ek}Zpe?mAggR9QO4B>kvslFiOv2v3;ipsJ%|MQ`$d8|=e`{LowvUq; z^MgCCnhPu02)aI6&BYlUB*zMU<%1%32LuFKzdW0;Gv*oMY5c`ENg)NNO%(k?K_JZ= z0d3a5Os5Q4RU1`)2?d(VDs=7KOUu=9q*;;Yx@l+#f({}=WTIg_j)8U)WXyjs)1y&|SV!NG^-AOkh1*IolY$5Q-gjA_ ze!%l#g%5W4UjNp(uiKp(-^KRLCY!K?9P4j*fBRr(YU`k)ho^D$cvsxAvwo6axjOSk zp_^;Mk`Uj;^-obFt1k0dM_Ft-CU7OFLSrKblSCI2+l|#P+!a(Rhva65s1Lk%Mj%Uq>T9BRMTw9(v25t09I;pFY~+~Kd$dvkc|L?9zZY8q zQ7lJ8Gq;svD7vo`T|IxOwm~gdI_S%p&gEa$H&9A`*;vEtm5suQkO8 z5EK*Chxu6Rrd;Q;J7xMHA`mm!W3uKBvG(haYbmWEqN86jZ4s zjQgtd0~xNg6AihTLB#D&Td^kIS9jO>_dN|fMv>NMhBM<&on}d#mFcRUGITe7CrhBM zxk~V9oo(JV+{&{0z+;)TMZ*7atu-=?w7s->WuAO~Onw$u_5PPht&y@<$pI1@N9Jjg z-?u+rD6949!x)ds3V1KA6$jrC8zuOOE{BE=&-@;Lr`VCN#}Y5P36eMpccyt9rcC>W zp5)erFf1@!HB0-F-hh9~sWiPl|8?*|J_-`G@OFsqV+9k{_gSM~)L*9yO|?WLy6dLw zKYBNa%{M?si|=LkVa7Z67pWukD4wl5SGN3D4U5cIz(~y}MPWRl*)y+h`2M0U>AH(SY?|%E)+C-jP|7%!Dq= zT?Z25Ex%FmU5`1`DbB~BZ-Vgo^P9BQEd%IQOuBABulkOns`kCg5SUjKsRJGqjkL_Y>Q;!(nrbCPjQ~W_@}@dZdVvd2zIa zt;6pt$u6kg_j{JUGp!VnG_v%HNH1KbeLCvpLe6&j@T2F4R@=~g>e$kNGi_6C6fI+QU~$r*SG~octjV-F%VmB};x?4Canod9{?=gP zRp+PoBKtifd-$+cPpIjsms`AR@TvxgxuQ}!U+)XETQ*({FvwJM zthpac(C#pTExBYSPg5#0%dZ^Cm${8B@Xf*h2|@NnN%FgvF_qlN4v)%a2>B8)$0YQ` z?+(mJd!*Em`ocPLv?oi1$-S4cOZaf;`Su!%Uwhw{BE-6vsf~>A znA*aCHRONilbE9QQ@aTfLR^B$ZUuF{#F4rVJ{I+PiT*gi4lZRq+S^fchL&FTZwNAy zC%t3o`6Riu?$XJv1T_S!feS)0cDr3xT22m9#sNN!$*5oC^1jT>g;@=lX@`w!mRZ|M zUJ&9%)3~N)*xBvQGY?L>->u>nf;6?9iq9;vwfWUOICfF&Q;aLA&kCKEn{8ildafq2 zH_pS`7DXu{d7Io4S!?Mni<1(o`+zbbwy^dlso&StQSR8?+f|fdV<}Lj^^IBo(6@wb z_oYsiP~x~ZkfV3hoLsM%CFzp9@|WPv=@Cf*cb{mgsUD*pMRH_yuU3U-IP zL|tSuidvqF>~50;ziL$PIS(hf;FV@BoH;z~u>asy?1N#ji1=O1r9U8@cMh8Lf z#nkBU5rH~zjh$&}gzk^ytq`u<5j;F^rUgNkI}@uPf>R5%4{QMJfQ?QSCZkI4&;>mF zeRy`yM5sXPdfi)+T;rY<`)eAR^ykhs6Zf9!Mj=Bu>A8R3)?}Qk-2%Tr?4tDO0sijw zr%4+04$I1re^C^OGhQZA} zSksOIz=WQOUYFxZ?;P=aikpMSoy?ZXUG`jmyk!MS0E=?WLE0}HRo)+vdY_CDYz$ZuY=st_%U z;kh$cALE}tPhSS;sp>dZrP>VMUuid;r2F?c{knQ#sW*=WRt=8*&~lo|Gn+_OUf&k= zHiiATgAQ6QNx{+HkQyygHunC4C~kry*n!+dON0(s-WJKXyM3kjky(mda4qx3-;_ch61let2p36(;(>wFh*3@ROuGbMu)M1xpo)1eWn?fYaYJe! zubOQh!n?YcHmHK6oy!?;p4Vdo;F*J~!fNKdor#@aGzH{5Om`xu3%z;azNB@`@TJ3nH(uQ$A z75>zAt}yCP-tykUa4B#f?R(=V8rN2aSG9}weSde0ghIS-)-pGM2_)|13d1%uL+!($ zIldMlyyevi^nV6ec?>vWE1Fn0ey0i%zow@h2Oi0dBR;4()hhX^H}()mkAVz8j=a#@ z{gB(0Y{&O)%mO?P3g!tEGwAr;AflJkpeLhh3Q%;{-_1Vo?{<@zG zpV!x-o=2gbrNH}cd>qcHI!jhQeRKb6X?|!lKn!)>*8n&Qh2NojAeQhqMqNe*Nb?oz zi~L$NeO+33@MUt;tyDGJPoZn-Sk(=Un}J?AEOg}0Lyyv3C~peDd~>qgpa>{&adN|+ zb9m%ul6wcE3%i9n;kkVwSY@AA(vFkNT%g=Z*L$=Z$hyv+f0&eyn2h+{ih*%{`Se3> zUhMO)z0~e{yIwv2{gj!{5)@pvpP%b3RLII8TqGn zSqj_9q`h%9=Fl{L#)wV6E&Pyw6pbp88z+3{!#Ps)m8Xe_Ge_QOK;cM`E_%=+*XN%q zKXiWnbi%4gxq^E{qHzzwyjWrG@vL5mrPH97W~0d}*G;{~-4-NUAu@4Ct>XIa-Gt*u z{i5<0v1w;Bu0WsMfI;)~vnrZ7>Q*k7i0l&ZJ zeC*-#umRw#Rs3Z8_Q&E?rf=#qU$dl77At!+EY5IB>maRfS;yp4QO@7K7(x^aOh_Jz zZbydl>k?2TnFd`!#d|%GYnyVc`lRTug**ov7Wo~|Dcu@gVjglrn~i;7D$~mjLj9=* z_HP_VPCyR}JUB$<$(P4E-cf7q*t_eY!hW5z3eP%c+X=5(S&CIF*hY-tKv5_0>K+Xk z7Jxat@wcEAbWCMo44s4Me~y+U{r`WVvOk&MJY$xfsHaYEoCgi6&bz0 zve{!u4xw_e#A!%AWCO?97>B$qUi`U0|AEZyN{s1vO`asD60hqsVB<->`@N+`{L9gKm;y zZi7HDcryzJLQj&|NrHnZ_Nly2x>rnQG}Y6;G>HoKouPmFS9;C4iBjB6siom!y-C&` zxjvvAsY&RF*$@{h#e2mQMYshM$V|ESQWV)`P?g)7JCEz3UgH_!NHwXKx!@_5bF5g8 z-ZpDSr_IAuo(E{A$@FjTyimjtxx>gAT4FZI#{mzF)XdV^i3r-|Bq=zrXFqz_kf# z&I9W1JHR3MoymvbaGT1wo!_%$gSv&G*iTUN9_z~o16a{PO#@~^eAPB*$6fRa)QBx$ zA&@3T(0g3(I*51ql@GXsQf&Te!RIdMU_>97wA`#_L}{~e8UZ#Jy}k@>1k(`*Tvv}V zaEQ6BiDTq=HzwhC*2<$x2v;vVox4?6N(|_{st+(pL%j_LnIz@@*$shsS>%l)xIxMv z0JQYo(PK=scBAoK=x16u(evf8z-hmfF*pb(291gXFF4O&mt47>V=VB>uBQZbW@2dn zVdeanwcp Date: Fri, 17 Jun 2022 11:39:01 -0700 Subject: [PATCH 319/349] Bugfix/securitydata search empty filter list (#378) * fix empty filter list bug * changelog and version bump * style * fix test in CI * fix test in CI for real * try again * mock the profile name * actually use the mock state :facepalm: * style --- CHANGELOG.md | 6 ++++++ src/code42cli/__version__.py | 2 +- src/code42cli/cmds/securitydata.py | 11 +++++++++++ tests/cmds/conftest.py | 11 +++++++++++ tests/cmds/test_securitydata.py | 22 ++++++++++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d73aa240..3f4743d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.14.2 - 2022-06-17 + +### Fixed + +- Bug where the `code42 security-data search` command using a checkpoint and only the `--include-non-exposure` filter constructed an invalid search query. + ## 1.14.1 - 2022-06-13 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 4454c8d4d..a19f6e1de 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.14.1" +__version__ = "1.14.2" diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 475e53266..4db388864 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -1,3 +1,4 @@ +import datetime from pprint import pformat import click @@ -474,6 +475,16 @@ def _construct_query(state, begin, end, saved_search, advanced_query, or_query): ) if or_query: state.search_filters = convert_to_or_query(state.search_filters) + + if not state.search_filters: + # if a checkpoint and _only_ --include-non-exposure is passed, the filter list will be empty, which isn't a + # valid query, so in that case we want to fallback to a 90 day (max event age) date range. The checkpoint will + # still cause the query results to only contain events after the checkpointed event. + _90_days = datetime.datetime.utcnow() - datetime.timedelta(days=90) + timestamp = convert_datetime_to_timestamp(_90_days) + state.search_filters.append( + create_time_range_filter(f.EventTimestamp, timestamp, None) + ) query = FileEventQuery(*state.search_filters) query.page_size = MAX_EVENT_PAGE_SIZE query.sort_direction = "asc" diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index bbd9beb8f..3dd9fea58 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -13,6 +13,7 @@ from tests.conftest import create_mock_response from tests.conftest import TEST_ID +from code42cli.cmds.search.cursor_store import FileEventCursorStore from code42cli.logger import CliLogger @@ -61,6 +62,16 @@ def cli_state_without_user(sdk_without_user, cli_state): return cli_state +@pytest.fixture +def mock_file_event_checkpoint(mocker): + mock_file_event_checkpointer = mocker.MagicMock(spec=FileEventCursorStore) + mocker.patch( + "code42cli.cmds.securitydata._get_file_event_cursor_store", + return_value=mock_file_event_checkpointer, + ) + return mock_file_event_checkpointer + + @pytest.fixture def custom_error(mocker): err = mocker.MagicMock(spec=HTTPError) diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 559753cd2..de1e76a8f 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -1,6 +1,7 @@ import json import logging +import pandas import py42.sdk.queries.fileevents.filters as f import pytest from py42.sdk.queries.fileevents.file_event_query import FileEventQuery @@ -1388,3 +1389,24 @@ def test_saved_search_list_with_format_option_does_not_return_when_response_is_e cli, ["security-data", "saved-search", "list", "-f", "csv"], obj=cli_state ) assert "Name,Id" not in result.output + + +def test_non_exposure_only_query_with_checkpoint_does_not_send_empty_filter_list( + runner, cli_state, mock_file_event_checkpoint, mocker +): + mock_file_event_checkpoint.get.return_value = "event_1234" + mock_get_all_file_events = mocker.patch( + "code42cli.cmds.securitydata._get_all_file_events" + ) + + def generator(): + yield pandas.DataFrame() + + mock_get_all_file_events.return_value = generator() + result = runner.invoke( + cli, + ["security-data", "search", "--include-non-exposure", "-c", "checkpoint"], + obj=cli_state, + ) + assert result.exit_code == 0 + assert len(mock_get_all_file_events.call_args[0][1]._filter_group_list) > 0 From 1969575e94a346c0d7e00cde21440b1e0053a44e Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Wed, 6 Jul 2022 10:02:03 -0500 Subject: [PATCH 320/349] adjust checkpoint filters when you want to retrieve all events (#380) * adjust checkpoint filters when you want to retrieve all events * Bugfix: only returning 10,000 events on first run * move checkpoint None -> empty-string check to _get_all_file_events() * release prep 1.14.3 --- CHANGELOG.md | 7 +++++++ src/code42cli/__version__.py | 2 +- src/code42cli/click_ext/groups.py | 2 ++ src/code42cli/cmds/securitydata.py | 11 ++++------- tests/cmds/test_securitydata.py | 12 ++++++++++++ 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4743d59..8e7480267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.14.3 - 2022-07-06 + +### Fixed + +- Bug where the `code42 security-data search` command using a checkpoint and only the `--include-non-exposure` filter resulted in invalid page tokens. +- Bug where `code42 security-data search` would only return 10,000 events on the first search when using a new checkpoint. + ## 1.14.2 - 2022-06-17 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index a19f6e1de..f38fc51b9 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.14.2" +__version__ = "1.14.3" diff --git a/src/code42cli/click_ext/groups.py b/src/code42cli/click_ext/groups.py index dadb59e78..b22e6a4ed 100644 --- a/src/code42cli/click_ext/groups.py +++ b/src/code42cli/click_ext/groups.py @@ -13,6 +13,7 @@ from py42.exceptions import Py42ForbiddenError from py42.exceptions import Py42HTTPError from py42.exceptions import Py42InvalidEmailError +from py42.exceptions import Py42InvalidPageTokenError from py42.exceptions import Py42InvalidPasswordError from py42.exceptions import Py42InvalidRuleOperationError from py42.exceptions import Py42InvalidUsernameError @@ -84,6 +85,7 @@ def invoke(self, ctx): Py42UpdateClosedCaseError, Py42UsernameMustBeEmailError, Py42InvalidEmailError, + Py42InvalidPageTokenError, Py42InvalidPasswordError, Py42InvalidUsernameError, Py42ActiveLegalHoldError, diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 4db388864..49fb0806f 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -1,4 +1,3 @@ -import datetime from pprint import pformat import click @@ -478,13 +477,9 @@ def _construct_query(state, begin, end, saved_search, advanced_query, or_query): if not state.search_filters: # if a checkpoint and _only_ --include-non-exposure is passed, the filter list will be empty, which isn't a - # valid query, so in that case we want to fallback to a 90 day (max event age) date range. The checkpoint will + # valid query, so in that case we want to fallback to retrieving all events. The checkpoint will # still cause the query results to only contain events after the checkpointed event. - _90_days = datetime.datetime.utcnow() - datetime.timedelta(days=90) - timestamp = convert_datetime_to_timestamp(_90_days) - state.search_filters.append( - create_time_range_filter(f.EventTimestamp, timestamp, None) - ) + state.search_filters.append(RiskSeverity.exists()) query = FileEventQuery(*state.search_filters) query.page_size = MAX_EVENT_PAGE_SIZE query.sort_direction = "asc" @@ -493,6 +488,8 @@ def _construct_query(state, begin, end, saved_search, advanced_query, or_query): def _get_all_file_events(state, query, checkpoint=""): + if checkpoint is None: + checkpoint = "" try: response = state.sdk.securitydata.search_all_file_events( query, page_token=checkpoint diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index de1e76a8f..8e529de22 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -4,6 +4,7 @@ import pandas import py42.sdk.queries.fileevents.filters as f import pytest +from py42.exceptions import Py42InvalidPageTokenError from py42.sdk.queries.fileevents.file_event_query import FileEventQuery from py42.sdk.queries.fileevents.filters import RiskIndicator from py42.sdk.queries.fileevents.filters import RiskSeverity @@ -327,6 +328,17 @@ def test_search_and_send_to_when_advanced_query_passed_non_existent_filename_rai assert "Could not open file: not_a_file" in result.stdout +@search_and_send_to_test +def test_search_and_send_to_when_given_invalid_page_token_raises_error( + runner, cli_state, custom_error, file_event_cursor_with_eventid_checkpoint, command +): + cli_state.sdk.securitydata.search_all_file_events.side_effect = ( + Py42InvalidPageTokenError(custom_error, TEST_FILE_EVENT_ID_2) + ) + result = runner.invoke(cli, [*command, "--use-checkpoint", "test"], obj=cli_state) + assert f'Invalid page token: "{TEST_FILE_EVENT_ID_2}"' in result.output + + @advanced_query_incompat_test_params def test_search_with_advanced_query_and_incompatible_argument_errors( runner, arg, cli_state From 5a000cff157f048be6230bea7a740b17e79ba254 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Thu, 21 Jul 2022 10:55:39 -0500 Subject: [PATCH 321/349] reduce page_size from py42 default (500) to 100 (#381) * reduce page_size from py42 default (500) to 100 * changelog * style * bump version --- CHANGELOG.md | 6 ++++++ src/code42cli/__version__.py | 2 +- src/code42cli/cmds/devices.py | 5 ++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7480267..d6ab30fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.14.4 - 2022-07-21 + +### Changed + +- Reduced the `page_size` in Device API calls from 500 to 100 to reduce possibility of timeouts when including backup usage in `code42 devices list`. + ## 1.14.3 - 2022-07-06 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index f38fc51b9..476b07061 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.14.3" +__version__ = "1.14.4" diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 12b2bbcae..2e59dffb5 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -432,7 +432,10 @@ def _get_device_dataframe( sdk, columns, active=None, org_uid=None, include_backup_usage=False ): devices_generator = sdk.devices.get_all( - active=active, include_backup_usage=include_backup_usage, org_uid=org_uid + active=active, + include_backup_usage=include_backup_usage, + org_uid=org_uid, + page_size=100, ) devices_list = [] if include_backup_usage: From ac60fa5c6c229a1c9ad432d33cff80a2e6efe9e0 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 1 Aug 2022 12:50:49 -0500 Subject: [PATCH 322/349] add --page-size option to devices cmds (#383) * add --page-size option to devices cmds * add default to help --- CHANGELOG.md | 6 ++++++ src/code42cli/__version__.py | 2 +- src/code42cli/cmds/devices.py | 27 ++++++++++++++++++++------- tests/cmds/test_devices.py | 24 ++++++++++++++++++++++-- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ab30fc3..669197e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.14.5 - 2022-08-01 + +### Added + +- `code42 devices list` and `code42 devices list-backup-sets` now accept a `--page-size ` option to enable manually configuring optimal page size. + ## 1.14.4 - 2022-07-21 ### Changed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 476b07061..dd8fc4166 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.14.4" +__version__ = "1.14.5" diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 2e59dffb5..6793daed2 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -243,6 +243,14 @@ def _get_device_info(sdk, device_guid): help="Limit devices to only those in the organization you specify. " "Note that child organizations will be included.", ) +page_size_option = click.option( + "--page-size", + required=False, + type=int, + default=100, + help="Number of devices to retrieve per API call. " + "Lower this value if you are getting timeouts when retrieving devices with backup info. Default: 100", +) include_usernames_option = click.option( "--include-usernames", @@ -323,6 +331,7 @@ def _get_device_info(sdk, device_guid): help="Include devices only when 'creationDate' field is greater than the provided value. " "Argument format options are the same as --last-connected-before.", ) +@page_size_option @format_option @sdk_options() def list_devices( @@ -340,6 +349,7 @@ def list_devices( last_connected_before, created_after, created_before, + page_size, format, ): """Get information about many devices.""" @@ -359,11 +369,12 @@ def list_devices( "userUid", ] df = _get_device_dataframe( - state.sdk, - columns, - active, - org_uid, - (include_backup_usage or include_total_storage), + sdk=state.sdk, + columns=columns, + page_size=page_size, + active=active, + org_uid=org_uid, + include_backup_usage=(include_backup_usage or include_total_storage), ) if exclude_most_recently_connected: most_recent = ( @@ -429,13 +440,13 @@ def _get_all_active_hold_memberships(sdk): def _get_device_dataframe( - sdk, columns, active=None, org_uid=None, include_backup_usage=False + sdk, columns, page_size, active=None, org_uid=None, include_backup_usage=False ): devices_generator = sdk.devices.get_all( active=active, include_backup_usage=include_backup_usage, org_uid=org_uid, - page_size=100, + page_size=page_size, ) devices_list = [] if include_backup_usage: @@ -514,6 +525,7 @@ def _break_backup_usage_into_total_storage(backup_usage): @inactive_option @org_uid_option @include_usernames_option +@page_size_option @format_option @sdk_options() def list_backup_sets( @@ -522,6 +534,7 @@ def list_backup_sets( inactive, org_uid, include_usernames, + page_size, format, ): """Get information about many devices and their backup sets.""" diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index 32183000b..ab7fdd087 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -690,7 +690,7 @@ def test_get_device_dataframe_returns_correct_columns( "osVersion", "userUid", ] - result = _get_device_dataframe(cli_state.sdk, columns) + result = _get_device_dataframe(cli_state.sdk, columns, page_size=100) assert "computerId" in result.columns assert "guid" in result.columns assert "name" in result.columns @@ -710,7 +710,9 @@ def test_get_device_dataframe_returns_correct_columns( def test_device_dataframe_return_includes_backupusage_when_flag_passed( cli_state, get_all_devices_success ): - result = _get_device_dataframe(cli_state.sdk, columns=[], include_backup_usage=True) + result = _get_device_dataframe( + cli_state.sdk, columns=[], page_size=100, include_backup_usage=True + ) assert "backupUsage" in result.columns @@ -738,6 +740,24 @@ def test_add_legal_hold_membership_to_device_dataframe_adds_legal_hold_columns_t assert "legalHoldName" in result.columns +def test_list_without_page_size_option_defaults_to_100_results_per_page( + cli_state, runner +): + runner.invoke(cli, ["devices", "list"], obj=cli_state) + cli_state.sdk.devices.get_all.assert_called_once_with( + active=None, include_backup_usage=False, org_uid=None, page_size=100 + ) + + +def test_list_with_page_size_option_sets_expected_page_size_in_request( + cli_state, runner +): + runner.invoke(cli, ["devices", "list", "--page-size", "1000"], obj=cli_state) + cli_state.sdk.devices.get_all.assert_called_once_with( + active=None, include_backup_usage=False, org_uid=None, page_size=1000 + ) + + def test_list_include_legal_hold_membership_pops_legal_hold_if_device_deactivated( cli_state, get_all_matter_success, get_all_custodian_success ): From 8a0eaced0e69233069159d35f9f729dd2eee994b Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 9 Aug 2022 08:53:56 -0500 Subject: [PATCH 323/349] Add v2 file events (#382) * Add v2 file events * check for profile setting in get_saved_search_option callback * use boolean options for profile settings * update changelog * fix build * Use risk.Severity.not_eq(NO_RISK_INDICATED) for --include-non-exposure option * pr feedback --- CHANGELOG.md | 10 ++ docs/commands/securitydata.rst | 8 + docs/guides.md | 2 + docs/userguides/v2apis.md | 186 ++++++++++++++++++++++ setup.py | 2 +- src/code42cli/cmds/profile.py | 55 +++++-- src/code42cli/cmds/search/options.py | 42 +++-- src/code42cli/cmds/securitydata.py | 226 +++++++++++++++++++++------ src/code42cli/cmds/util.py | 7 + src/code42cli/config.py | 24 ++- src/code42cli/profile.py | 16 +- tests/cmds/test_profile.py | 39 +++-- tests/cmds/test_securitydata.py | 113 +++++++++++++- tests/conftest.py | 5 +- tests/test_config.py | 22 ++- tests/test_profile.py | 8 +- 16 files changed, 669 insertions(+), 96 deletions(-) create mode 100644 docs/userguides/v2apis.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 669197e93..b9ca62eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added +- Support for the V2 file event data model. + - V1 file event APIs were marked deprecated in May 2022 and will be no longer be supported after May 2023. + - Use the `--use-v2-file-events True` option with the `code42 profile create` or `code42 profile update` commands to enable your code42 CLI profile to use the latest V2 file event data model. + - See the [V2 File Events User Guide](https://clidocs.code42.com/en/latest/userguides/siemexample.html) for more information. + +### Changed +- The `--disable-ssl-errors` options for the `code42 profile create` and `code42 profile update` commands is no longer a flag and now takes a boolean `True/False` arg. ## 1.14.5 - 2022-08-01 ### Added diff --git a/docs/commands/securitydata.rst b/docs/commands/securitydata.rst index d00966b9c..f0eaa317c 100644 --- a/docs/commands/securitydata.rst +++ b/docs/commands/securitydata.rst @@ -1,3 +1,11 @@ +************* +Security Data +************* + +.. warning:: V1 file events, saved searches, and queries are **deprecated**. + +See more information in the `Enable V2 File Events User Guide <../userguides/v2apis.html>`_. + .. click:: code42cli.cmds.securitydata:security_data :prog: security-data :nested: full diff --git a/docs/guides.md b/docs/guides.md index 489b87f87..86e9bc4f4 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -8,6 +8,7 @@ Get started with the Code42 command-line interface (CLI) Configure a profile + Enable V2 File Events Ingest data into a SIEM Manage legal hold users Clean up your environment by deactivating devices @@ -23,6 +24,7 @@ * [Get started with the Code42 command-line interface (CLI)](userguides/gettingstarted.md) * [Configure a profile](userguides/profile.md) +* [Enable V2 File Events](userguides/v2apis.md) * [Ingest data into a SIEM](userguides/siemexample.md) * [Manage legal hold users](userguides/legalhold.md) * [Clean up your environment by deactivating devices](userguides/deactivatedevices.md) diff --git a/docs/userguides/v2apis.md b/docs/userguides/v2apis.md new file mode 100644 index 000000000..cb01150d7 --- /dev/null +++ b/docs/userguides/v2apis.md @@ -0,0 +1,186 @@ +# V2 File Events + +```{eval-rst} +.. warning:: V1 file events, saved searches, and queries are **deprecated**. +``` + +For details on the updated File Event Model, see the V2 File Events API documentation on the [Developer Portal](https://developer.code42.com/api/#tag/File-Events). + +V1 file event APIs were marked deprecated in May 2022 and will be no longer be supported after May 2023. + +Use the `--use-v2-file-events True` option with the `code42 profile create` or `code42 profile update` commands to enable your code42 CLI profile to use the latest V2 file event data model. + +Use `code42 profile show` to check the status of this setting on your profile: +```bash +% code42 profile update --use-v2-file-events + +% code42 profile show + +test-user-profile: + * username = test-user@code42.com + * authority url = https://console.core-int.cloud.code42.com + * ignore-ssl-errors = False + * use-v2-file-events = True + +``` + +For details on setting up a profile, see the [profile set up user guide](./profile.md). + +Enabling this setting will use the V2 data model for querying searches and saved searches with all `code security-data` commands. +The response shape for these events has changed from V1 and contains various field remappings, renamings, additions and removals. Column names will also be different when using the `Table` format for outputting events. + +### V2 File Event Data Example ### + +Below is an example of the new file event data model: + +```json +{ + "@timestamp": "2022-07-14T16:53:06.112Z", + "event": { + "id": "0_c4e43418-07d9-4a9f-a138-29f39a124d33_1068825680073059134_1068826271084047166_1_EPS", + "inserted": "2022-07-14T16:57:00.913917Z", + "action": "application-read", + "observer": "Endpoint", + "shareType": [], + "ingested": "2022-07-14T16:55:04.723Z", + "relatedEvents": [] + }, + "user": { + "email": "engineer@example.com", + "id": "1068824450489230065", + "deviceUid": "1068825680073059134" + }, + "file": { + "name": "cat.jpg", + "directory": "C:/Users/John Doe/Downloads/", + "category": "Spreadsheet", + "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "categoryByBytes": "Spreadsheet", + "mimeTypeByExtension": "image/jpeg", + "categoryByExtension": "Image", + "sizeInBytes": 4748, + "owner": "John Doe", + "created": "2022-07-14T16:51:06.186Z", + "modified": "2022-07-14T16:51:07.419Z", + "hash": { + "md5": "8872dfa1c181b823d2c00675ae5926fd", + "sha256": "14d749cce008711b4ad1381d84374539560340622f0e8b9eb2fe3bba77ddbd64", + "md5Error": null, + "sha256Error": null + }, + "id": null, + "url": null, + "directoryId": [], + "cloudDriveId": null, + "classifications": [] + }, + "report": { + "id": null, + "name": null, + "description": null, + "headers": [], + "count": null, + "type": null + }, + "source": { + "category": "Device", + "name": "DESKTOP-1", + "domain": "192.168.00.000", + "ip": "50.237.00.00", + "privateIp": [ + "192.168.00.000", + "127.0.0.1" + ], + "operatingSystem": "Windows 10", + "email": { + "sender": null, + "from": null + }, + "removableMedia": { + "vendor": null, + "name": null, + "serialNumber": null, + "capacity": null, + "busType": null, + "mediaName": null, + "volumeName": [], + "partitionId": [] + }, + "tabs": [], + "domains": [] + }, + "destination": { + "category": "Cloud Storage", + "name": "Dropbox", + "user": { + "email": [] + }, + "ip": null, + "privateIp": [], + "operatingSystem": null, + "printJobName": null, + "printerName": null, + "printedFilesBackupPath": null, + "removableMedia": { + "vendor": null, + "name": null, + "serialNumber": null, + "capacity": null, + "busType": null, + "mediaName": null, + "volumeName": [], + "partitionId": [] + }, + "email": { + "recipients": null, + "subject": null + }, + "tabs": [ + { + "title": "Files - Dropbox and 1 more page - Profile 1 - Microsoft​ Edge", + "url": "https://www.dropbox.com/home", + "titleError": null, + "urlError": null + } + ], + "accountName": null, + "accountType": null, + "domains": [ + "dropbox.com" + ] + }, + "process": { + "executable": "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + "owner": "John doe" + }, + "risk": { + "score": 17, + "severity": "CRITICAL", + "indicators": [ + { + "name": "First use of destination", + "weight": 3 + }, + { + "name": "File mismatch", + "weight": 9 + }, + { + "name": "Spreadsheet", + "weight": 0 + }, + { + "name": "Remote", + "weight": 0 + }, + { + "name": "Dropbox upload", + "weight": 5 + } + ], + "trusted": false, + "trustReason": null + } +} + +``` diff --git a/setup.py b/setup.py index afad965de..38d45afcc 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython==7.16.3", "pandas>=1.1.3", - "py42>=1.23.0", + "py42>=1.24.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 0a3e56165..35b7744f9 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -70,9 +70,16 @@ def username_option(required=False): disable_ssl_option = click.option( "--disable-ssl-errors", - is_flag=True, + type=click.types.BOOL, help="For development purposes, do not validate the SSL certificates of Code42 servers. " - "This is not recommended, except for specific scenarios like testing.", + "This is not recommended, except for specific scenarios like testing. Attach this flag to the update command to toggle the setting.", + default=None, +) + +use_v2_file_events_option = click.option( + "--use-v2-file-events", + type=click.types.BOOL, + help="Opts to use the V2 file event data model. Attach this flag to the update command to toggle the setting", default=None, ) @@ -86,6 +93,7 @@ def show(profile_name): echo(f"\t* username = {c42profile.username}") echo(f"\t* authority url = {c42profile.authority_url}") echo(f"\t* ignore-ssl-errors = {c42profile.ignore_ssl_errors}") + echo(f"\t* use-v2-file-events = {c42profile.use_v2_file_events}") if cliprofile.get_stored_password(c42profile.name) is not None: echo("\t* A password is set.") echo("") @@ -100,10 +108,22 @@ def show(profile_name): @totp_option @yes_option(hidden=True) @disable_ssl_option +@use_v2_file_events_option @debug_option -def create(name, server, username, password, disable_ssl_errors, debug, totp): +def create( + name, + server, + username, + password, + disable_ssl_errors, + use_v2_file_events, + debug, + totp, +): """Create profile settings. The first profile created will be the default.""" - cliprofile.create_profile(name, server, username, disable_ssl_errors) + cliprofile.create_profile( + name, server, username, disable_ssl_errors, use_v2_file_events + ) password = password or _prompt_for_password(name) if password: _set_pw(name, password, debug, totp=totp) @@ -117,18 +137,35 @@ def create(name, server, username, password, disable_ssl_errors, debug, totp): @password_option @totp_option @disable_ssl_option +@use_v2_file_events_option @debug_option -def update(name, server, username, password, disable_ssl_errors, debug, totp): +def update( + name, + server, + username, + password, + disable_ssl_errors, + use_v2_file_events, + debug, + totp, +): """Update an existing profile.""" c42profile = cliprofile.get_profile(name) - if not server and not username and not password and disable_ssl_errors is None: + if ( + not server + and not username + and not password + and disable_ssl_errors is None + and use_v2_file_events is None + ): raise click.UsageError( - "Must provide at least one of `--username`, `--server`, `--password`, or " + "Must provide at least one of `--username`, `--server`, `--password`, `--use-v2-file-events` or " "`--disable-ssl-errors` when updating a profile." ) - - cliprofile.update_profile(c42profile.name, server, username, disable_ssl_errors) + cliprofile.update_profile( + c42profile.name, server, username, disable_ssl_errors, use_v2_file_events + ) if not password and not c42profile.has_stored_password: password = _prompt_for_password(c42profile.name) if password: diff --git a/src/code42cli/cmds/search/options.py b/src/code42cli/cmds/search/options.py index 49bd78076..885d94a08 100644 --- a/src/code42cli/cmds/search/options.py +++ b/src/code42cli/cmds/search/options.py @@ -10,48 +10,63 @@ from code42cli.logger.enums import ServerProtocol -def is_in_filter(filter_cls): +def is_in_filter(filter_cls, filter_cls_v2=None): def callback(ctx, param, arg): if arg: - ctx.obj.search_filters.append(filter_cls.is_in(arg)) + f = filter_cls + if filter_cls_v2 and ctx.obj.profile.use_v2_file_events == "True": + f = filter_cls_v2 + ctx.obj.search_filters.append(f.is_in(arg)) return arg return callback -def not_in_filter(filter_cls): +def not_in_filter(filter_cls, filter_cls_v2=None): def callback(ctx, param, arg): if arg: - ctx.obj.search_filters.append(filter_cls.not_in(arg)) + f = filter_cls + if filter_cls_v2 and ctx.obj.profile.use_v2_file_events == "True": + f = filter_cls_v2 + ctx.obj.search_filters.append(f.not_in(arg)) return arg return callback -def exists_filter(filter_cls): +def exists_filter(filter_cls, filter_cls_v2=None): def callback(ctx, param, arg): if not arg: - ctx.obj.search_filters.append(filter_cls.exists()) + f = filter_cls + if filter_cls_v2 and ctx.obj.profile.use_v2_file_events == "True": + f = filter_cls_v2 + ctx.obj.search_filters.append(f.exists()) return arg return callback -def contains_filter(filter_cls): +def contains_filter(filter_cls, filter_cls_v2=None): def callback(ctx, param, arg): if arg: + f = filter_cls + if filter_cls_v2 and ctx.obj.profile.use_v2_file_events == "True": + f = filter_cls_v2 for item in arg: - ctx.obj.search_filters.append(filter_cls.contains(item)) + ctx.obj.search_filters.append(f.contains(item)) return arg return callback -def not_contains_filter(filter_cls): +def not_contains_filter(filter_cls, filter_cls_v2=None): def callback(ctx, param, arg): if arg: + f = filter_cls + if filter_cls_v2 and ctx.obj.profile.use_v2_file_events == "True": + f = filter_cls_v2 for item in arg: - ctx.obj.search_filters.append(filter_cls.not_contains(item)) + ctx.obj.search_filters.append(f.not_contains(item)) return arg return callback @@ -61,6 +76,13 @@ def callback(ctx, param, arg): ["advanced_query", "saved_search"] ) +ExposureTypeIncompatible = incompatible_with( + ["advanced_query", "saved_search", "event_action"] +) +EventActionIncompatible = incompatible_with( + ["advanced_query", "saved_search", "exposure_type"] +) + class BeginOption(AdvancedQueryAndSavedSearchIncompatible): """click.Option subclass that enforces correct --begin option usage.""" diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 49fb0806f..b959169c5 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -4,6 +4,7 @@ import py42.sdk.queries.fileevents.filters as f from click import echo from pandas import DataFrame +from pandas import json_normalize from py42.exceptions import Py42InvalidPageTokenError from py42.sdk.queries.fileevents.file_event_query import FileEventQuery from py42.sdk.queries.fileevents.filters import InsertionTimestamp @@ -11,6 +12,8 @@ from py42.sdk.queries.fileevents.filters.file_filter import FileCategory from py42.sdk.queries.fileevents.filters.risk_filter import RiskIndicator from py42.sdk.queries.fileevents.filters.risk_filter import RiskSeverity +from py42.sdk.queries.fileevents.v2 import FileEventQuery as FileEventQueryV2 +from py42.sdk.queries.fileevents.v2 import filters as v2_filters import code42cli.cmds.search.options as searchopt import code42cli.options as opt @@ -24,6 +27,7 @@ from code42cli.date_helper import convert_datetime_to_timestamp from code42cli.date_helper import limit_date_range from code42cli.enums import OutputFormat +from code42cli.errors import Code42CLIError from code42cli.logger import get_main_cli_logger from code42cli.options import column_option from code42cli.options import format_option @@ -35,8 +39,51 @@ logger = get_main_cli_logger() MAX_EVENT_PAGE_SIZE = 10000 - SECURITY_DATA_KEYWORD = "file events" + + +def exposure_type_callback(): + def callback(ctx, param, arg): + if arg: + if ctx.obj.profile.use_v2_file_events == "True": + raise Code42CLIError( + "Exposure type (--type/-t) filter is incompatible with V2 file events. Use the event action (--event-action) filter instead." + ) + ctx.obj.search_filters.append(ExposureType.is_in(arg)) + return arg + + return callback + + +def event_action_callback(): + def callback(ctx, param, arg): + if arg: + if ctx.obj.profile.use_v2_file_events == "False": + raise Code42CLIError( + "Event action (--event-action) filter is incompatible with V1 file events. Upgrade your profile to use the V2 file event data model with `code42 profile update --use-v2-file-events`" + ) + ctx.obj.search_filters.append(v2_filters.event.Action.is_in(arg)) + return arg + + return callback + + +def get_all_events_callback(): + def callback(ctx, param, arg): + if not arg: + if ctx.obj.profile.use_v2_file_events == "True": + ctx.obj.search_filters.append( + v2_filters.risk.Severity.not_eq( + v2_filters.risk.Severity.NO_RISK_INDICATED + ) + ) + else: + ctx.obj.search_filters.append(ExposureType.exists()) + return arg + + return callback + + file_events_format_option = click.option( "-f", "--format", @@ -49,21 +96,29 @@ "--type", multiple=True, type=click.Choice(list(ExposureType.choices())), - cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, - callback=searchopt.is_in_filter(f.ExposureType), - help="Limits events to those with given exposure types.", + cls=searchopt.ExposureTypeIncompatible, + callback=exposure_type_callback(), + help="Limits events to those with given exposure types. Only compatible with V1 file events.", +) +event_action_option = click.option( + "--event-action", + multiple=True, + type=click.Choice(list(v2_filters.event.Action.choices())), + cls=searchopt.EventActionIncompatible, + callback=event_action_callback(), + help="Limits events to those with given event action. Only compatible with V2 file events.", ) username_option = click.option( "--c42-username", multiple=True, - callback=searchopt.is_in_filter(f.DeviceUsername), + callback=searchopt.is_in_filter(f.DeviceUsername, v2_filters.user.Email), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to endpoint events for these Code42 users.", ) actor_option = click.option( "--actor", multiple=True, - callback=searchopt.is_in_filter(f.Actor), + callback=searchopt.is_in_filter(f.Actor, v2_filters.user.Email), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to only those enacted by the cloud service user " "of the person who caused the event.", @@ -71,35 +126,35 @@ md5_option = click.option( "--md5", multiple=True, - callback=searchopt.is_in_filter(f.MD5), + callback=searchopt.is_in_filter(f.MD5, v2_filters.file.MD5), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file has one of these MD5 hashes.", ) sha256_option = click.option( "--sha256", multiple=True, - callback=searchopt.is_in_filter(f.SHA256), + callback=searchopt.is_in_filter(f.SHA256, v2_filters.file.SHA256), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file has one of these SHA256 hashes.", ) source_option = click.option( "--source", multiple=True, - callback=searchopt.is_in_filter(f.Source), + callback=searchopt.is_in_filter(f.Source, v2_filters.source.Name), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to only those from one of these sources. For example, Gmail, Box, or Endpoint.", ) file_name_option = click.option( "--file-name", multiple=True, - callback=searchopt.is_in_filter(f.FileName), + callback=searchopt.is_in_filter(f.FileName, v2_filters.file.Name), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file has one of these names.", ) file_path_option = click.option( "--file-path", multiple=True, - callback=searchopt.is_in_filter(f.FilePath), + callback=searchopt.is_in_filter(f.FilePath, v2_filters.file.Directory), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file is located at one of these paths. Applies to endpoint file events only.", ) @@ -125,14 +180,14 @@ "Zip": FileCategory.ZIP, }, ), - callback=searchopt.is_in_filter(f.FileCategory), + callback=searchopt.is_in_filter(f.FileCategory, v2_filters.file.Category), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to file events where the file can be classified by one of these categories.", ) process_owner_option = click.option( "--process-owner", multiple=True, - callback=searchopt.is_in_filter(f.ProcessOwner), + callback=searchopt.is_in_filter(f.ProcessOwner, v2_filters.process.Owner), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits exposure events by process owner, as reported by the device’s operating system. " "Applies only to `Printed` and `Browser or app read` events.", @@ -140,14 +195,16 @@ tab_url_option = click.option( "--tab-url", multiple=True, - callback=searchopt.is_in_filter(f.TabURL), + callback=searchopt.is_in_filter(f.TabURL, v2_filters.destination.TabUrls), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to be exposure events with one of the specified destination tab URLs.", ) + + include_non_exposure_option = click.option( "--include-non-exposure", is_flag=True, - callback=searchopt.exists_filter(f.ExposureType), + callback=get_all_events_callback(), cls=incompatible_with(["advanced_query", "type", "saved_search"]), help="Get all events including non-exposure events.", ) @@ -219,11 +276,14 @@ risk_indicator_map_reversed = {v: k for k, v in risk_indicator_map.items()} -def risk_indicator_callback(filter_cls): +def risk_indicator_callback(): def callback(ctx, param, arg): if arg: + f_cls = f.RiskIndicator + if ctx.obj.profile.use_v2_file_events == "True": + f_cls = v2_filters.risk.Indicators mapped_args = tuple(risk_indicator_map[i] for i in arg) - filter_func = searchopt.is_in_filter(filter_cls) + filter_func = searchopt.is_in_filter(f_cls) return filter_func(ctx, param, mapped_args) return callback @@ -236,7 +296,7 @@ def callback(ctx, param, arg): choices=list(risk_indicator_map.keys()), extras_map=risk_indicator_map_reversed, ), - callback=risk_indicator_callback(f.RiskIndicator), + callback=risk_indicator_callback(), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to those classified by the given risk indicator categories.", ) @@ -244,7 +304,7 @@ def callback(ctx, param, arg): "--risk-severity", multiple=True, type=click.Choice(list(RiskSeverity.choices())), - callback=searchopt.is_in_filter(f.RiskSeverity), + callback=searchopt.is_in_filter(f.RiskSeverity, v2_filters.risk.Severity), cls=searchopt.AdvancedQueryAndSavedSearchIncompatible, help="Limits events to those classified by the given risk severity.", ) @@ -265,7 +325,9 @@ def _get_saved_search_option(): def _get_saved_search_query(ctx, param, arg): if arg is None: return - query = ctx.obj.sdk.securitydata.savedsearches.get_query(arg) + query = ctx.obj.sdk.securitydata.savedsearches.get_query( + arg, use_v2=ctx.obj.profile.use_v2_file_events == "True" + ) return query return click.option( @@ -289,6 +351,7 @@ def search_options(f): def file_event_options(f): f = exposure_type_option(f) + f = event_action_option(f) f = username_option(f) f = actor_option(f) f = md5_option(f) @@ -342,41 +405,73 @@ def search( include_all, **kwargs, ): - """Search for file events.""" if format == FileEventsOutputFormat.CEF and columns: raise click.BadOptionUsage( "columns", "--columns option can't be used with CEF format." ) + + # cef format unsupported for v2 file events + if ( + format == FileEventsOutputFormat.CEF + and state.profile.use_v2_file_events == "True" + ): + raise click.BadOptionUsage( + "format", "--format CEF is unsupported for v2 file events." + ) + # set default table columns if format == OutputFormat.TABLE: if not columns and not include_all: - columns = [ - "fileName", - "filePath", - "eventType", - "eventTimestamp", - "fileCategory", - "fileSize", - "fileOwner", - "md5Checksum", - "sha256Checksum", - "riskIndicators", - "riskSeverity", - ] + if state.profile.use_v2_file_events == "True": + columns = [ + "@timestamp", + "file.name", + "file.directory", + "event.action", + "file.category", + "file.sizeInBytes", + "file.owner", + "file.hash.md5", + "file.hash.sha256", + "risk.indicators", + "risk.severity", + ] + else: + columns = [ + "fileName", + "filePath", + "eventType", + "eventTimestamp", + "fileCategory", + "fileSize", + "fileOwner", + "md5Checksum", + "sha256Checksum", + "riskIndicators", + "riskSeverity", + ] if use_checkpoint: cursor = _get_file_event_cursor_store(state.profile.name) checkpoint = _handle_timestamp_checkpoint(cursor.get(use_checkpoint), state) - def checkpoint_func(event): - cursor.replace(use_checkpoint, event["eventId"]) + if state.profile.use_v2_file_events == "True": + + def checkpoint_func(event): + cursor.replace(use_checkpoint, event["event.id"]) + + else: + + def checkpoint_func(event): + cursor.replace(use_checkpoint, event["eventId"]) else: checkpoint = checkpoint_func = None query = _construct_query(state, begin, end, saved_search, advanced_query, or_query) - dfs = _get_all_file_events(state, query, checkpoint) + flatten = format in (OutputFormat.TABLE, OutputFormat.CSV) + dfs = _get_all_file_events(state, query, checkpoint, flatten) formatter = FileEventsOutputFormatter(format, checkpoint_func=checkpoint_func) # sending to pager when checkpointing can be inaccurate due to pager buffering, so disallow pager force_no_pager = use_checkpoint @@ -410,8 +505,15 @@ def send_to( cursor = _get_file_event_cursor_store(state.profile.name) checkpoint = _handle_timestamp_checkpoint(cursor.get(use_checkpoint), state) - def checkpoint_func(event): - cursor.replace(use_checkpoint, event["eventId"]) + if state.profile.use_v2_file_events == "True": + + def checkpoint_func(event): + cursor.replace(use_checkpoint, event["event.id"]) + + else: + + def checkpoint_func(event): + cursor.replace(use_checkpoint, event["eventId"]) else: checkpoint = checkpoint_func = None @@ -441,7 +543,9 @@ def saved_search(state): def _list(state, format=None): """List available saved searches.""" formatter = DataFrameOutputFormatter(format) - response = state.sdk.securitydata.savedsearches.get() + response = state.sdk.securitydata.savedsearches.get( + use_v2=state.profile.use_v2_file_events == "True" + ) saved_searches_df = DataFrame(response["searches"]) formatter.echo_formatted_dataframes( saved_searches_df, columns=["name", "id", "notes"] @@ -453,7 +557,9 @@ def _list(state, format=None): @sdk_options() def show(state, search_id): """Get the details of a saved search.""" - response = state.sdk.securitydata.savedsearches.get_by_id(search_id) + response = state.sdk.securitydata.savedsearches.get_by_id( + search_id, use_v2=state.profile.use_v2_file_events == "True" + ) echo(pformat(response["searches"])) @@ -469,8 +575,13 @@ def _construct_query(state, begin, end, saved_search, advanced_query, or_query): state.search_filters = saved_search._filter_group_list else: if begin or end: + timestamp_class = ( + v2_filters.timestamp.Timestamp + if state.profile.use_v2_file_events == "True" + else f.EventTimestamp + ) state.search_filters.append( - create_time_range_filter(f.EventTimestamp, begin, end) + create_time_range_filter(timestamp_class, begin, end) ) if or_query: state.search_filters = convert_to_or_query(state.search_filters) @@ -480,14 +591,20 @@ def _construct_query(state, begin, end, saved_search, advanced_query, or_query): # valid query, so in that case we want to fallback to retrieving all events. The checkpoint will # still cause the query results to only contain events after the checkpointed event. state.search_filters.append(RiskSeverity.exists()) - query = FileEventQuery(*state.search_filters) + + # construct a v2 model query if profile setting enabled + if state.profile.use_v2_file_events == "True": + query = FileEventQueryV2(*state.search_filters) + query.sort_key = "@timestamp" + else: + query = FileEventQuery(*state.search_filters) + query.sort_key = "insertionTimestamp" query.page_size = MAX_EVENT_PAGE_SIZE query.sort_direction = "asc" - query.sort_key = "insertionTimestamp" return query -def _get_all_file_events(state, query, checkpoint=""): +def _get_all_file_events(state, query, checkpoint="", flatten=False): if checkpoint is None: checkpoint = "" try: @@ -496,18 +613,31 @@ def _get_all_file_events(state, query, checkpoint=""): ) except Py42InvalidPageTokenError: response = state.sdk.securitydata.search_all_file_events(query) - yield DataFrame(response["fileEvents"]) + + data = response["fileEvents"] + if data and flatten: + data = json_normalize(data) + yield DataFrame(data) + while response["nextPgToken"]: response = state.sdk.securitydata.search_all_file_events( query, page_token=response["nextPgToken"] ) - yield DataFrame(response["fileEvents"]) + data = response["fileEvents"] + if data and flatten: + data = json_normalize(data) + yield DataFrame(data) def _handle_timestamp_checkpoint(checkpoint, state): try: checkpoint = float(checkpoint) - state.search_filters.append(InsertionTimestamp.on_or_after(checkpoint)) + if state.profile.use_v2_file_events == "True": + state.search_filters.append( + v2_filters.timestamp.Timestamp.on_or_after(checkpoint) + ) + else: + state.search_filters.append(InsertionTimestamp.on_or_after(checkpoint)) return None except (ValueError, TypeError): return checkpoint diff --git a/src/code42cli/cmds/util.py b/src/code42cli/cmds/util.py index 911bab8ca..6841f5c36 100644 --- a/src/code42cli/cmds/util.py +++ b/src/code42cli/cmds/util.py @@ -4,6 +4,9 @@ from py42.sdk.queries.fileevents.filters import EventTimestamp from py42.sdk.queries.fileevents.filters import ExposureType from py42.sdk.queries.fileevents.filters import InsertionTimestamp +from py42.sdk.queries.fileevents.v2.filters.event import Inserted +from py42.sdk.queries.fileevents.v2.filters.risk import Severity +from py42.sdk.queries.fileevents.v2.filters.timestamp import Timestamp from py42.sdk.queries.query_filter import FilterGroup from py42.sdk.queries.query_filter import QueryFilterTimestampField @@ -40,6 +43,10 @@ def _is_exempt_filter(f): EventTimestamp, DateObserved, ExposureType.exists(), + # V2 Filters + Timestamp, + Inserted, + Severity.not_eq(Severity.NO_RISK_INDICATED), ] for exempt in or_query_exempt_filters: diff --git a/src/code42cli/config.py b/src/code42cli/config.py index 81f6fbceb..769cdbad4 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -19,6 +19,7 @@ class ConfigAccessor: AUTHORITY_KEY = "c42_authority_url" USERNAME_KEY = "c42_username" IGNORE_SSL_ERRORS_KEY = "ignore-ssl-errors" + USE_V2_FILE_EVENTS_KEY = "use-v2-file-events" DEFAULT_PROFILE = "default_profile" _INTERNAL_SECTION = "Internal" @@ -51,7 +52,9 @@ def get_all_profiles(self): profiles.append(self.get_profile(name)) return profiles - def create_profile(self, name, server, username, ignore_ssl_errors): + def create_profile( + self, name, server, username, ignore_ssl_errors, use_v2_file_events + ): """Creates a new profile if one does not already exist for that name.""" try: self.get_profile(name) @@ -62,10 +65,19 @@ def create_profile(self, name, server, username, ignore_ssl_errors): raise ex profile = self.get_profile(name) - self.update_profile(profile.name, server, username, ignore_ssl_errors) + self.update_profile( + profile.name, server, username, ignore_ssl_errors, use_v2_file_events + ) self._try_complete_setup(profile) - def update_profile(self, name, server=None, username=None, ignore_ssl_errors=None): + def update_profile( + self, + name, + server=None, + username=None, + ignore_ssl_errors=None, + use_v2_file_events=None, + ): profile = self.get_profile(name) if server: self._set_authority_url(server, profile) @@ -73,6 +85,8 @@ def update_profile(self, name, server=None, username=None, ignore_ssl_errors=Non self._set_username(username, profile) if ignore_ssl_errors is not None: self._set_ignore_ssl_errors(ignore_ssl_errors, profile) + if use_v2_file_events is not None: + self._set_use_v2_file_events(use_v2_file_events, profile) self._save() def switch_default_profile(self, new_default_name): @@ -100,6 +114,9 @@ def _set_username(self, new_value, profile): def _set_ignore_ssl_errors(self, new_value, profile): profile[self.IGNORE_SSL_ERRORS_KEY] = str(new_value) + def _set_use_v2_file_events(self, new_value, profile): + profile[self.USE_V2_FILE_EVENTS_KEY] = str(new_value) + def _get_sections(self): return self.parser.sections() @@ -130,6 +147,7 @@ def _create_profile_section(self, name): self.parser[name][self.AUTHORITY_KEY] = self.DEFAULT_VALUE self.parser[name][self.USERNAME_KEY] = self.DEFAULT_VALUE self.parser[name][self.IGNORE_SSL_ERRORS_KEY] = str(False) + self.parser[name][self.USE_V2_FILE_EVENTS_KEY] = str(False) def _save(self): with open(self.path, "w+", encoding="utf-8") as file: diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index a16bd4840..b3621d661 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -28,6 +28,10 @@ def username(self): def ignore_ssl_errors(self): return self._profile[ConfigAccessor.IGNORE_SSL_ERRORS_KEY] + @property + def use_v2_file_events(self): + return self._profile.get(ConfigAccessor.USE_V2_FILE_EVENTS_KEY) + @property def has_stored_password(self): stored_password = password.get_stored_password(self) @@ -99,10 +103,12 @@ def switch_default_profile(profile_name): config_accessor.switch_default_profile(profile.name) -def create_profile(name, server, username, ignore_ssl_errors): +def create_profile(name, server, username, ignore_ssl_errors, use_v2_file_events): if profile_exists(name): raise Code42CLIError(f"A profile named '{name}' already exists.") - config_accessor.create_profile(name, server, username, ignore_ssl_errors) + config_accessor.create_profile( + name, server, username, ignore_ssl_errors, use_v2_file_events + ) def delete_profile(profile_name): @@ -116,8 +122,10 @@ def delete_profile(profile_name): config_accessor.delete_profile(profile_name) -def update_profile(name, server, username, ignore_ssl_errors): - config_accessor.update_profile(name, server, username, ignore_ssl_errors) +def update_profile(name, server, username, ignore_ssl_errors, use_v2_file_events): + config_accessor.update_profile( + name, server, username, ignore_ssl_errors, use_v2_file_events + ) def get_all_profiles(): diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index b77e5a801..474a76090 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -98,10 +98,11 @@ def test_create_profile_if_user_sets_password_is_created( "-u", "baz", "--disable-ssl-errors", + "True", ], ) mock_cliprofile_namespace.create_profile.assert_called_once_with( - "foo", "bar", "baz", True + "foo", "bar", "baz", True, None ) @@ -121,10 +122,13 @@ def test_create_profile_if_user_does_not_set_password_is_created( "-u", "baz", "--disable-ssl-errors", + "True", + "--use-v2-file-events", + "True", ], ) mock_cliprofile_namespace.create_profile.assert_called_once_with( - "foo", "bar", "baz", True + "foo", "bar", "baz", True, True ) @@ -144,6 +148,7 @@ def test_create_profile_if_user_does_not_agree_does_not_save_password( "-u", "baz", "--disable-ssl-errors", + "True", ], ) assert not mock_cliprofile_namespace.set_password.call_count @@ -236,6 +241,7 @@ def test_create_profile_outputs_confirmation( "-u", "baz", "--disable-ssl-errors", + "True", ], ) assert "Successfully created profile 'foo'." in result.output @@ -259,10 +265,11 @@ def test_update_profile_updates_existing_profile( "-u", "baz", "--disable-ssl-errors", + "True", ], ) mock_cliprofile_namespace.update_profile.assert_called_once_with( - name, "bar", "baz", True + name, "bar", "baz", True, None ) @@ -274,10 +281,21 @@ def test_update_profile_updates_default_profile( mock_cliprofile_namespace.get_profile.return_value = profile runner.invoke( cli, - ["profile", "update", "-s", "bar", "-u", "baz", "--disable-ssl-errors"], + [ + "profile", + "update", + "-s", + "bar", + "-u", + "baz", + "--disable-ssl-errors", + "True", + "--use-v2-file-events", + "True", + ], ) mock_cliprofile_namespace.update_profile.assert_called_once_with( - name, "bar", "baz", True + name, "bar", "baz", True, True ) @@ -289,10 +307,10 @@ def test_update_profile_updates_name_alone( mock_cliprofile_namespace.get_profile.return_value = profile runner.invoke( cli, - ["profile", "update", "-u", "baz", "--disable-ssl-errors"], + ["profile", "update", "-u", "baz", "--disable-ssl-errors", "True"], ) mock_cliprofile_namespace.update_profile.assert_called_once_with( - name, None, "baz", True + name, None, "baz", True, None ) @@ -314,6 +332,7 @@ def test_update_profile_if_user_does_not_agree_does_not_save_password( "-u", "baz", "--disable-ssl-errors", + "True", ], ) assert not mock_cliprofile_namespace.set_password.call_count @@ -339,6 +358,7 @@ def test_update_profile_if_credentials_invalid_password_not_saved( "-u", "baz", "--disable-ssl-errors", + "True", ], ) assert not mock_cliprofile_namespace.set_password.call_count @@ -364,6 +384,7 @@ def test_update_profile_if_user_agrees_and_valid_connection_sets_password( "-u", "baz", "--disable-ssl-errors", + "True", ], ) mock_cliprofile_namespace.set_password.assert_called_once_with( @@ -376,12 +397,12 @@ def test_update_profile_when_given_zero_args_prints_error_message( ): name = "foo" profile.name = name - profile.ignore_ssl_errors = False + profile.ignore_ssl_errors = "False" mock_cliprofile_namespace.get_profile.return_value = profile result = runner.invoke(cli, ["profile", "update"]) expected = ( "Must provide at least one of `--username`, `--server`, `--password`, " - "or `--disable-ssl-errors` when updating a profile." + "`--use-v2-file-events` or `--disable-ssl-errors` when updating a profile." ) assert "Profile 'foo' has been updated" not in result.output assert expected in result.output diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 8e529de22..16b8daca3 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -9,6 +9,7 @@ from py42.sdk.queries.fileevents.filters import RiskIndicator from py42.sdk.queries.fileevents.filters import RiskSeverity from py42.sdk.queries.fileevents.filters.file_filter import FileCategory +from py42.sdk.queries.fileevents.v2 import filters as v2_filters from tests.cmds.conftest import filter_term_is_in_call_args from tests.cmds.conftest import get_mark_for_search_and_send_to from tests.conftest import create_mock_response @@ -1374,7 +1375,9 @@ def test_saved_search_show_detail_calls_get_by_id_method(runner, cli_state): runner.invoke( cli, ["security-data", "saved-search", "show", test_id], obj=cli_state ) - cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with(test_id) + cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with( + test_id, use_v2=False + ) def test_saved_search_list_with_format_option_returns_csv_formatted_response( @@ -1422,3 +1425,111 @@ def generator(): ) assert result.exit_code == 0 assert len(mock_get_all_file_events.call_args[0][1]._filter_group_list) > 0 + + +def test_saved_search_get_by_id_uses_v2_flag_if_settings_enabled(runner, cli_state): + cli_state.profile.use_v2_file_events = "True" + test_saved_search_id = "123-test-saved-search" + runner.invoke( + cli, + ["security-data", "saved-search", "show", test_saved_search_id], + obj=cli_state, + ) + cli_state.profile.use_v2_file_events = "False" + cli_state.sdk.securitydata.savedsearches.get_by_id.assert_called_once_with( + test_saved_search_id, use_v2=True + ) + + +def test_saved_search_list_uses_v2_flag_if_settings_enabled(runner, cli_state): + cli_state.profile.use_v2_file_events = "True" + runner.invoke(cli, ["security-data", "saved-search", "list"], obj=cli_state) + cli_state.profile.use_v2_file_events = "False" + cli_state.sdk.securitydata.savedsearches.get.assert_called_once_with(use_v2=True) + + +def test_exposure_type_raises_exception_when_called_with_v2_settings_enabled( + runner, cli_state +): + cli_state.profile.use_v2_file_events = "True" + result = runner.invoke( + cli, + ["security-data", "search", "-b", "10d", "--type", "IsPublic"], + obj=cli_state, + ) + cli_state.profile.use_v2_file_events = "False" + assert result.exit_code == 1 + assert ( + "Exposure type (--type/-t) filter is incompatible with V2 file events. Use the event action (--event-action) filter instead." + in result.output + ) + + +def test_event_action_raises_exception_when_called_with_v2_settings_disabled( + runner, cli_state +): + cli_state.profile.use_v2_file_events = "False" + result = runner.invoke( + cli, + ["security-data", "search", "-b", "10d", "--event-action", "file-created"], + obj=cli_state, + ) + assert result.exit_code == 1 + assert ( + "Event action (--event-action) filter is incompatible with V1 file events. Upgrade your profile to use the V2 file event data model with `code42 profile update --use-v2-file-events`" + in result.output + ) + + +@search_and_send_to_test +def test_search_and_send_to_builds_correct_query_when_v2_events_enabled( + runner, cli_state, command, search_all_file_events_success +): + cli_state.profile.use_v2_file_events = "True" + cmd = [ + *command, + "--begin", + "1d", + "--event-action", + "file-created", + "--c42-username", + "test-username", + "--md5", + "test-md5-hash", + "--sha256", + "test-sha256-hash", + "--source", + "Gmail", + "--file-name", + "my-test-file.txt", + "--file-path", + "my/test-directory/", + "--file-category", + "DOCUMENT", + "--process-owner", + "test-owner", + "--tab-url", + "google.com", + "--risk-indicator", + "SOURCE_CODE", + ] + runner.invoke(cli, cmd, obj=cli_state) + cli_state.profile.use_v2_file_events = "False" + query = cli_state.sdk.securitydata.search_all_file_events.call_args[0][0] + + filter_objs = [ + v2_filters.event.Action.is_in(["file-created"]), + v2_filters.user.Email.is_in(["test-username"]), + v2_filters.file.MD5.is_in(["test-md5-hash"]), + v2_filters.file.SHA256.is_in(["test-sha256-hash"]), + v2_filters.source.Name.is_in(["Gmail"]), + v2_filters.file.Name.is_in(["my-test-file.txt"]), + v2_filters.file.Directory.is_in(["my/test-directory/"]), + v2_filters.file.Category.is_in(["Document"]), + v2_filters.process.Owner.is_in(["test-owner"]), + v2_filters.destination.TabUrls.is_in(["google.com"]), + v2_filters.risk.Severity.not_eq(v2_filters.risk.Severity.NO_RISK_INDICATED), + v2_filters.risk.Indicators.is_in(["Source code"]), + ] + for filter_obj in filter_objs: + assert filter_obj in query._filter_group_list diff --git a/tests/conftest.py b/tests/conftest.py index 4fbcaa8b9..7e809eb85 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,11 +86,14 @@ def alert_namespace(): return args -def create_profile_values_dict(authority=None, username=None, ignore_ssl=False): +def create_profile_values_dict( + authority=None, username=None, ignore_ssl=False, use_v2_file_events=False +): return { ConfigAccessor.AUTHORITY_KEY: "example.com", ConfigAccessor.USERNAME_KEY: "foo", ConfigAccessor.IGNORE_SSL_ERRORS_KEY: True, + ConfigAccessor.USE_V2_FILE_EVENTS_KEY: False, } diff --git a/tests/test_config.py b/tests/test_config.py index 6db938c97..89ee271e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -152,7 +152,9 @@ def test_create_profile_when_given_default_name_does_not_create( ): accessor = ConfigAccessor(config_parser_for_create) with pytest.raises(Exception): - accessor.create_profile(ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False) + accessor.create_profile( + ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False, False + ) def test_create_profile_when_no_default_profile_sets_default( self, mocker, config_parser_for_create, mock_saver @@ -163,7 +165,7 @@ def test_create_profile_when_no_default_profile_sets_default( accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) assert accessor.switch_default_profile.call_count == 1 def test_create_profile_when_has_default_profile_does_not_set_default( @@ -175,7 +177,7 @@ def test_create_profile_when_has_default_profile_does_not_set_default( accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) assert not accessor.switch_default_profile.call_count def test_create_profile_when_not_existing_saves( @@ -186,7 +188,7 @@ def test_create_profile_when_not_existing_saves( setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", False) + accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) assert mock_saver.call_count def test_update_profile_when_no_profile_exists_raises_exception( @@ -201,7 +203,7 @@ def test_update_profile_updates_profile(self, config_parser_for_multiple_profile address = "NEW ADDRESS" username = "NEW USERNAME" - accessor.update_profile(_TEST_PROFILE_NAME, address, username, True) + accessor.update_profile(_TEST_PROFILE_NAME, address, username, True, True) assert ( accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.AUTHORITY_KEY] == address @@ -213,6 +215,9 @@ def test_update_profile_updates_profile(self, config_parser_for_multiple_profile assert accessor.get_profile(_TEST_PROFILE_NAME)[ ConfigAccessor.IGNORE_SSL_ERRORS_KEY ] + assert accessor.get_profile(_TEST_PROFILE_NAME)[ + ConfigAccessor.USE_V2_FILE_EVENTS_KEY + ] def test_update_profile_does_not_update_when_given_none( self, config_parser_for_multiple_profiles @@ -222,9 +227,9 @@ def test_update_profile_does_not_update_when_given_none( # First, make sure they're not None address = "NOT NONE" username = "NOT NONE" - accessor.update_profile(_TEST_PROFILE_NAME, address, username, True) + accessor.update_profile(_TEST_PROFILE_NAME, address, username, True, True) - accessor.update_profile(_TEST_PROFILE_NAME, None, None, None) + accessor.update_profile(_TEST_PROFILE_NAME, None, None, None, None) assert ( accessor.get_profile(_TEST_PROFILE_NAME)[ConfigAccessor.AUTHORITY_KEY] == address @@ -236,3 +241,6 @@ def test_update_profile_does_not_update_when_given_none( assert accessor.get_profile(_TEST_PROFILE_NAME)[ ConfigAccessor.IGNORE_SSL_ERRORS_KEY ] + assert accessor.get_profile(_TEST_PROFILE_NAME)[ + ConfigAccessor.USE_V2_FILE_EVENTS_KEY + ] diff --git a/tests/test_profile.py b/tests/test_profile.py index ce22c6992..95164d484 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -138,9 +138,11 @@ def test_create_profile_uses_expected_profile_values(config_accessor): server = "server" username = "username" ssl_errors_disabled = True - cliprofile.create_profile(profile_name, server, username, ssl_errors_disabled) + cliprofile.create_profile( + profile_name, server, username, ssl_errors_disabled, False + ) config_accessor.create_profile.assert_called_once_with( - profile_name, server, username, ssl_errors_disabled + profile_name, server, username, ssl_errors_disabled, False ) @@ -149,7 +151,7 @@ def test_create_profile_if_profile_exists_exits( ): config_accessor.get_profile.return_value = mocker.MagicMock() with pytest.raises(Code42CLIError): - cliprofile.create_profile("foo", "bar", "baz", True) + cliprofile.create_profile("foo", "bar", "baz", True, False) def test_get_all_profiles_returns_expected_profile_list(config_accessor): From e408e8023f244abb959b1e71d11da06dd98cc518 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Mon, 22 Aug 2022 13:24:19 -0500 Subject: [PATCH 324/349] V1 file event deprecation warnings (#384) * V1 file event deprecation warnings * add true arg to option --- docs/userguides/v2apis.md | 2 +- src/code42cli/cmds/securitydata.py | 25 +++++++++++++++++++++++-- tests/cmds/test_securitydata.py | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/userguides/v2apis.md b/docs/userguides/v2apis.md index cb01150d7..c429bbc9d 100644 --- a/docs/userguides/v2apis.md +++ b/docs/userguides/v2apis.md @@ -12,7 +12,7 @@ Use the `--use-v2-file-events True` option with the `code42 profile create` or ` Use `code42 profile show` to check the status of this setting on your profile: ```bash -% code42 profile update --use-v2-file-events +% code42 profile update --use-v2-file-events True % code42 profile show diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index b959169c5..ba5eb1086 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -35,10 +35,13 @@ from code42cli.output_formats import DataFrameOutputFormatter from code42cli.output_formats import FileEventsOutputFormat from code42cli.output_formats import FileEventsOutputFormatter +from code42cli.util import deprecation_warning from code42cli.util import warn_interrupt logger = get_main_cli_logger() MAX_EVENT_PAGE_SIZE = 10000 +DEPRECATION_TEXT = "(DEPRECATED): V1 file events are deprecated. Update your profile with `code42 profile update --use-v2-file-events True` to use the new V2 file event data model." + SECURITY_DATA_KEYWORD = "file events" @@ -60,7 +63,7 @@ def callback(ctx, param, arg): if arg: if ctx.obj.profile.use_v2_file_events == "False": raise Code42CLIError( - "Event action (--event-action) filter is incompatible with V1 file events. Upgrade your profile to use the V2 file event data model with `code42 profile update --use-v2-file-events`" + "Event action (--event-action) filter is incompatible with V1 file events. Upgrade your profile to use the V2 file event data model with `code42 profile update --use-v2-file-events True`" ) ctx.obj.search_filters.append(v2_filters.event.Action.is_in(arg)) return arg @@ -406,6 +409,10 @@ def search( **kwargs, ): """Search for file events.""" + + if state.profile.use_v2_file_events != "True": + deprecation_warning(DEPRECATION_TEXT) + if format == FileEventsOutputFormat.CEF and columns: raise click.BadOptionUsage( "columns", "--columns option can't be used with CEF format." @@ -501,6 +508,9 @@ def send_to( HOSTNAME format: address:port where port is optional and defaults to 514. """ + if state.profile.use_v2_file_events != "True": + deprecation_warning(DEPRECATION_TEXT) + if use_checkpoint: cursor = _get_file_event_cursor_store(state.profile.name) checkpoint = _handle_timestamp_checkpoint(cursor.get(use_checkpoint), state) @@ -542,6 +552,9 @@ def saved_search(state): @sdk_options() def _list(state, format=None): """List available saved searches.""" + if state.profile.use_v2_file_events != "True": + deprecation_warning(DEPRECATION_TEXT) + formatter = DataFrameOutputFormatter(format) response = state.sdk.securitydata.savedsearches.get( use_v2=state.profile.use_v2_file_events == "True" @@ -557,6 +570,9 @@ def _list(state, format=None): @sdk_options() def show(state, search_id): """Get the details of a saved search.""" + if state.profile.use_v2_file_events != "True": + deprecation_warning(DEPRECATION_TEXT) + response = state.sdk.securitydata.savedsearches.get_by_id( search_id, use_v2=state.profile.use_v2_file_events == "True" ) @@ -590,7 +606,12 @@ def _construct_query(state, begin, end, saved_search, advanced_query, or_query): # if a checkpoint and _only_ --include-non-exposure is passed, the filter list will be empty, which isn't a # valid query, so in that case we want to fallback to retrieving all events. The checkpoint will # still cause the query results to only contain events after the checkpointed event. - state.search_filters.append(RiskSeverity.exists()) + severity_filter = ( + v2_filters.risk.Severity.exists() + if state.profile.use_v2_file_events == "True" + else RiskSeverity.exists() + ) + state.search_filters.append(severity_filter) # construct a v2 model query if profile setting enabled if state.profile.use_v2_file_events == "True": diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index 16b8daca3..b41e16fc9 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -1476,7 +1476,7 @@ def test_event_action_raises_exception_when_called_with_v2_settings_disabled( ) assert result.exit_code == 1 assert ( - "Event action (--event-action) filter is incompatible with V1 file events. Upgrade your profile to use the V2 file event data model with `code42 profile update --use-v2-file-events`" + "Event action (--event-action) filter is incompatible with V1 file events. Upgrade your profile to use the V2 file event data model with `code42 profile update --use-v2-file-events True`" in result.output ) From b0a83229300bd1fe874381cd7699493083c1e194 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 23 Aug 2022 09:08:25 -0500 Subject: [PATCH 325/349] prep 1.15.0 release (#386) --- CHANGELOG.md | 5 ++++- src/code42cli/__version__.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ca62eb1..a33b82dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.15.0 - 2022-08-23 ### Added + - Support for the V2 file event data model. - V1 file event APIs were marked deprecated in May 2022 and will be no longer be supported after May 2023. - Use the `--use-v2-file-events True` option with the `code42 profile create` or `code42 profile update` commands to enable your code42 CLI profile to use the latest V2 file event data model. - See the [V2 File Events User Guide](https://clidocs.code42.com/en/latest/userguides/siemexample.html) for more information. ### Changed + - The `--disable-ssl-errors` options for the `code42 profile create` and `code42 profile update` commands is no longer a flag and now takes a boolean `True/False` arg. + ## 1.14.5 - 2022-08-01 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index dd8fc4166..6b0872cb2 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.14.5" +__version__ = "1.15.0" From 74d4184d369c0289d47c18343159e6d6abfe65f8 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Tue, 30 Aug 2022 09:59:12 -0500 Subject: [PATCH 326/349] proxy support (#387) * proxy support * doc note and allow lower-case env var * style --- CHANGELOG.md | 6 ++++++ docs/userguides/gettingstarted.md | 10 +++++++++- src/code42cli/sdk_client.py | 9 +++++++++ tests/test_sdk_client.py | 12 ++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a33b82dd7..f193e31aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Added + +- Proxy support via `HTTPS_PROXY` environment variable. + ## 1.15.0 - 2022-08-23 ### Added diff --git a/docs/userguides/gettingstarted.md b/docs/userguides/gettingstarted.md index c58e5e0a9..abfcf366a 100644 --- a/docs/userguides/gettingstarted.md +++ b/docs/userguides/gettingstarted.md @@ -85,13 +85,21 @@ Password (TOTP) must be provided at every invocation of the CLI, either via the The Code42 CLI currently does **not** support SSO login providers or any other identity providers such as Active Directory or Okta. +## Proxy Support + +```{eval-rst} +.. note:: Proxy support was added in code42cli version 1.16.0 +``` + +The Code42 CLI will attempt to connect through a proxy if the `https_proxy`/`HTTPS_PROXY` environment variable is set. + ### Windows and Mac For Windows and Mac systems, the CLI uses Keyring when storing passwords. ### Red Hat Enterprise Linux -To use Keyring to store the credentials you enter in the Code42 CLI, enter the following commands before installing. +To use Keyring to store the credentials you 2enter in the Code42 CLI, enter the following commands before installing. ```bash yum -y install python-pip python3 dbus-python gnome-keyring libsecret dbus-x11 pip3 install code42cli diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index 514b837c6..aa3d7aefc 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -1,3 +1,5 @@ +from os import environ + import py42.sdk import py42.settings import py42.settings.debug as debug @@ -19,6 +21,9 @@ def create_sdk(profile, is_debug_mode, password=None, totp=None): + proxy = environ.get("HTTPS_PROXY") or environ.get("https_proxy") + if proxy: + py42.settings.proxies = {"https": proxy} if is_debug_mode: py42.settings.debug.level = debug.DEBUG if profile.ignore_ssl_errors == "True": @@ -46,6 +51,10 @@ def _validate_connection(authority_url, username, password, totp=None): ) except ConnectionError as err: logger.log_error(err) + if "ProxyError" in str(err): + raise LoggedCLIError( + f"Unable to connect to proxy! Proxy configuration set by environment variable: HTTPS_PROXY={environ.get('HTTPS_PROXY')}" + ) raise LoggedCLIError(f"Problem connecting to {authority_url}.") except Py42UnauthorizedError as err: logger.log_error(err) diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index b67666a1b..d297e960b 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -116,6 +116,18 @@ def test_create_sdk_uses_given_credentials( ) +@pytest.mark.parametrize("proxy_env", ["HTTPS_PROXY", "https_proxy"]) +def test_create_sdk_uses_proxy_when_env_var_set( + mock_profile_with_password, monkeypatch, proxy_env +): + monkeypatch.setenv(proxy_env, "http://test.domain") + with pytest.raises(LoggedCLIError) as err: + create_sdk(mock_profile_with_password, False) + + assert "Unable to connect to proxy!" in str(err.value) + assert py42.settings.proxies["https"] == "http://test.domain" + + def test_create_sdk_connection_when_2FA_login_config_detected_prompts_for_totp( mocker, monkeypatch, mock_sdk_factory, capsys, mock_profile_with_password ): From cc2b586fd447fdfa10c3ea8b8f6d55749700135e Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 13 Sep 2022 12:50:23 -0500 Subject: [PATCH 327/349] Add users show-risk-profile and list-risk-profiles commands (#388) * Add users show-risk-profile and list-risk-profiles commands * update changelog --- CHANGELOG.md | 3 ++ src/code42cli/cmds/users.py | 65 ++++++++++++++++++++++++++++++ tests/cmds/test_users.py | 79 +++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f193e31aa..23608d6a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- New commands to view details for user risk profiles: + - `code42 users list-risk-profiles` + - `code42 users show-risk-profile` - Proxy support via `HTTPS_PROXY` environment variable. ## 1.15.0 - 2022-08-23 diff --git a/src/code42cli/cmds/users.py b/src/code42cli/cmds/users.py index 5f345c41b..284d7b228 100644 --- a/src/code42cli/cmds/users.py +++ b/src/code42cli/cmds/users.py @@ -131,6 +131,71 @@ def show_user(state, username, include_legal_hold_membership, format): formatter.echo_formatted_dataframes(df) +@users.command(name="list-risk-profiles") +@active_option +@inactive_option +@click.option( + "--manager-id", + help="Matches users whose manager has the given Code42 user ID.", +) +@click.option("--department", help="Matches users in the given department.") +@click.option("--employment-type", help="Matches users with the given employment type.") +@click.option("-r", "--region", help="Matches users the given region (state).") +@format_option +@sdk_options() +def list_user_risk_profiles( + state, + active, + inactive, + manager_id, + department, + employment_type, + region, + format, +): + """List users in your Code42 environment.""" + if inactive: + active = False + columns = ( + [ + "userId", + "username", + "active", + "department", + "employmentType", + "region", + "endDate", + ] + if format == OutputFormat.TABLE + else None + ) + users_generator = state.sdk.userriskprofile.get_all( + active=active, + manager_id=manager_id, + department=department, + employment_type=employment_type, + region=region, + ) + users_list = [] + for page in users_generator: + users_list.extend(page["userRiskProfiles"]) + + df = DataFrame.from_records(users_list, columns=columns) + formatter = DataFrameOutputFormatter(format) + formatter.echo_formatted_dataframes(df) + + +@users.command("show-risk-profile") +@username_arg +@format_option +@sdk_options() +def show_user_risk_profile(state, username, format): + """Show user risk profile details.""" + formatter = OutputFormatter(format) + response = state.sdk.userriskprofile.get_by_username(username) + formatter.echo_formatted_list([response.data]) + + @users.command() @username_option("Username of the target user.") @role_name_option("Name of role to add.") diff --git a/tests/cmds/test_users.py b/tests/cmds/test_users.py index a9006f6b2..32bcc3be8 100644 --- a/tests/cmds/test_users.py +++ b/tests/cmds/test_users.py @@ -63,6 +63,30 @@ "country": "US", "riskFactors": ["FLIGHT_RISK", "HIGH_IMPACT_EMPLOYEE"], } +TEST_PROFILE_RESPONSE = { + "userId": "12345-42", + "tenantId": "SampleTenant1", + "username": "foo@bar.com", + "displayName": "Foo Bar", + "notes": "", + "managerId": "123-42", + "managerUsername": "test@bar.com", + "managerDisplayName": "", + "title": "Engineer", + "division": "Engineering", + "department": "RDO", + "employmentType": "Remote", + "country": "USA", + "region": "Minnesota", + "locality": "Minneapolis", + "active": True, + "deleted": False, + "supportUser": False, + "startDate": {"year": 2020, "month": 8, "day": 10}, + "endDate": {"year": 2021, "month": 5, "day": 1}, + "cloudAliases": ["baz@bar.com", "foo@bar.com"], +} + TEST_MATTER_RESPONSE = { "legalHolds": [ {"legalHoldUid": "123456789", "name": "Legal Hold #1", "active": True}, @@ -615,6 +639,61 @@ def test_show_include_legal_hold_membership_merges_in_and_concats_legal_hold_inf assert "123456789,987654321" in result.output +def test_list_risk_profiles_calls_get_all_user_risk_profiles_with_default_parameters( + runner, cli_state +): + runner.invoke( + cli, + ["users", "list-risk-profiles"], + obj=cli_state, + ) + cli_state.sdk.userriskprofile.get_all.assert_called_once_with( + active=None, manager_id=None, department=None, employment_type=None, region=None + ) + + +def test_list_risk_profiles_calls_get_all_user_risk_profiles_with_correct_parameters( + runner, cli_state +): + r = runner.invoke( + cli, + [ + "users", + "list-risk-profiles", + "--active", + "--manager-id", + "123-42", + "--department", + "Engineering", + "--employment-type", + "Remote", + "--region", + "Minnesota", + ], + obj=cli_state, + ) + print(r.output) + cli_state.sdk.userriskprofile.get_all.assert_called_once_with( + active=True, + manager_id="123-42", + department="Engineering", + employment_type="Remote", + region="Minnesota", + ) + + +def test_show_risk_profile_calls_user_risk_profile_get_by_username_with( + runner, cli_state, get_users_response +): + runner.invoke( + cli, + ["users", "show-risk-profile", "foo@bar.com"], + obj=cli_state, + ) + + cli_state.sdk.userriskprofile.get_by_username.assert_called_once_with("foo@bar.com") + + def test_add_user_role_adds( runner, cli_state, get_user_id_success, get_available_roles_success ): From 1ec784db8c87757ae22eb8d57095063b10dad050 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Tue, 27 Sep 2022 08:07:08 -0500 Subject: [PATCH 328/349] Feature/api clients (#389) * api client support * add api client auth section to profile user guide * fix unit tests * suggest wrapping secrets in single quotes * reword to username/password authentication * init sdk in bulk remove command * minor change tp update cmd param check * update unit tests --- CHANGELOG.md | 7 + docs/userguides/profile.md | 17 ++- docs/userguides/v2apis.md | 1 + setup.py | 2 +- src/code42cli/cmds/legal_hold.py | 98 +++++++++----- src/code42cli/cmds/profile.py | 216 +++++++++++++++++++++++++++---- src/code42cli/config.py | 52 ++++---- src/code42cli/options.py | 7 +- src/code42cli/profile.py | 42 ++++-- src/code42cli/sdk_client.py | 12 +- tests/cmds/test_profile.py | 142 +++++++++++++++++++- tests/conftest.py | 11 +- tests/test_config.py | 14 +- tests/test_profile.py | 34 ++++- tests/test_sdk_client.py | 19 +++ 15 files changed, 552 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23608d6a9..f13371670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta ### Added +- Support for Code42 API clients. + - You can create a new profile with API client authentication using `code42 profile create-api-client` + - Or, update your existing profile to use API clients with `code42 update --api-client-id --secret ` +- When using API client authentication, changes to the following `legal-hold` commands: + - `code42 legal-hold list` - Change in response shape. + - `code42 legal-hold show` - Change in response shape. + - `code42 legal-hold search-events` - **Not available.** - New commands to view details for user risk profiles: - `code42 users list-risk-profiles` - `code42 users show-risk-profile` diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md index d1e248d40..da9dcac40 100644 --- a/docs/userguides/profile.md +++ b/docs/userguides/profile.md @@ -3,7 +3,9 @@ Use the [code42 profile](../commands/profile.md) set of commands to establish the Code42 environment you're working within and your user information. -First, create your profile: +## User token authentication + +Use the following command to create your profile with user token authentication: ```bash code42 profile create --name MY_FIRST_PROFILE --server example.authority.com --username security.admin@example.com ``` @@ -15,6 +17,19 @@ Your password is not shown when you do `code42 profile show`. However, `code42 p password exists for your profile. If you do not set a password, you will be securely prompted to enter a password each time you run a command. +## API client authentication + +Once you've generated an API Client in your Code42 console, use the following command to create your profile with API client authentication: +```bash +code42 profile create-api-client --name MY_API_CLIENT_PROFILE --server example.authority.com --api-client-id "key-42" --secret "code42%api%client%secret" +``` + +```{eval-rst} +.. note:: Remember to wrap your API client secret with single quotes to avoid issues with bash expansion and special characters. +``` + +## View profiles + You can add multiple profiles with different names and the change the default profile with the `use` command: ```bash diff --git a/docs/userguides/v2apis.md b/docs/userguides/v2apis.md index c429bbc9d..59366a15d 100644 --- a/docs/userguides/v2apis.md +++ b/docs/userguides/v2apis.md @@ -11,6 +11,7 @@ V1 file event APIs were marked deprecated in May 2022 and will be no longer be s Use the `--use-v2-file-events True` option with the `code42 profile create` or `code42 profile update` commands to enable your code42 CLI profile to use the latest V2 file event data model. Use `code42 profile show` to check the status of this setting on your profile: + ```bash % code42 profile update --use-v2-file-events True diff --git a/setup.py b/setup.py index 38d45afcc..c123e5b6e 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "keyrings.alt==3.2.0", "ipython==7.16.3", "pandas>=1.1.3", - "py42>=1.24.0", + "py42>=1.26.0", ], extras_require={ "dev": [ diff --git a/src/code42cli/cmds/legal_hold.py b/src/code42cli/cmds/legal_hold.py index b8fe02343..df17d5c6e 100644 --- a/src/code42cli/cmds/legal_hold.py +++ b/src/code42cli/cmds/legal_hold.py @@ -4,6 +4,7 @@ import click from click import echo +from click import style from code42cli.bulk import generate_template_cmd_factory from code42cli.bulk import run_bulk_process @@ -89,7 +90,7 @@ def add_user(state, matter_id, username): @sdk_options() def remove_user(state, matter_id, username): """Release a custodian from a legal hold matter.""" - _remove_user_from_legal_hold(state.sdk, matter_id, username) + _remove_user_from_legal_hold(state, state.sdk, matter_id, username) @legal_hold.command("list") @@ -98,7 +99,7 @@ def remove_user(state, matter_id, username): def _list(state, format=None): """Fetch existing legal hold matters.""" formatter = OutputFormatter(format, _MATTER_KEYS_MAP) - matters = _get_all_active_matters(state.sdk) + matters = _get_all_active_matters(state) if matters: formatter.echo_formatted_list(matters) @@ -120,14 +121,21 @@ def _list(state, format=None): def show(state, matter_id, include_inactive=False, include_policy=False): """Display details of a given legal hold matter.""" matter = _check_matter_is_accessible(state.sdk, matter_id) - matter["creator_username"] = matter["creator"]["username"] + + if state.profile.api_client_auth == "True": + try: + matter["creator_username"] = matter["creator"]["user"]["email"] + except KeyError: + pass + else: + matter["creator_username"] = matter["creator"]["username"] matter = json.loads(matter.text) # if `active` is None then all matters (whether active or inactive) are returned. True returns # only those that are active. active = None if include_inactive else True memberships = _get_legal_hold_memberships_for_matter( - state.sdk, matter_id, active=active + state, state.sdk, matter_id, active=active ) active_usernames = [ member["user"]["username"] for member in memberships if member["active"] @@ -161,6 +169,15 @@ def show(state, matter_id, include_inactive=False, include_policy=False): @sdk_options() def search_events(state, matter_id, event_type, begin, end, format): """Tools for getting legal hold event data.""" + if state.profile.api_client_auth == "True": + echo( + style( + "WARNING: This method is unavailable with API Client Authentication.", + fg="red", + ), + err=True, + ) + formatter = OutputFormatter(format, _EVENT_KEYS_MAP) events = _get_all_events(state.sdk, matter_id, begin, end) if event_type: @@ -214,7 +231,7 @@ def remove(state, csv_rows): sdk = state.sdk def handle_row(matter_id, username): - _remove_user_from_legal_hold(sdk, matter_id, username) + _remove_user_from_legal_hold(state, sdk, matter_id, username) run_bulk_process( handle_row, csv_rows, progress_label="Removing users from legal hold:" @@ -227,11 +244,20 @@ def _add_user_to_legal_hold(sdk, matter_id, username): sdk.legalhold.add_to_matter(user_id, matter_id) -def _remove_user_from_legal_hold(sdk, matter_id, username): +def _remove_user_from_legal_hold(state, sdk, matter_id, username): _check_matter_is_accessible(sdk, matter_id) - membership_id = _get_legal_hold_membership_id_for_user_and_matter( - sdk, username, matter_id + + user_id = get_user_id(sdk, username) + memberships = _get_legal_hold_memberships_for_matter( + state, sdk, matter_id, active=True ) + membership_id = None + for member in memberships: + if member["user"]["userUid"] == user_id: + membership_id = member["legalHoldMembershipUid"] + if not membership_id: + raise UserNotInLegalHoldError(username, matter_id) + sdk.legalhold.remove_from_matter(membership_id) @@ -241,37 +267,41 @@ def _get_and_print_preservation_policy(sdk, policy_uid): echo(pformat(json.loads(preservation_policy.text))) -def _get_legal_hold_membership_id_for_user_and_matter(sdk, username, matter_id): - user_id = get_user_id(sdk, username) - memberships = _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True) - for member in memberships: - if member["user"]["userUid"] == user_id: - return member["legalHoldMembershipUid"] - raise UserNotInLegalHoldError(username, matter_id) - - -def _get_legal_hold_memberships_for_matter(sdk, matter_id, active=True): +def _get_legal_hold_memberships_for_matter(state, sdk, matter_id, active=True): memberships_generator = sdk.legalhold.get_all_matter_custodians( - legal_hold_uid=matter_id, active=active + matter_id, active=active ) - memberships = [ - member - for page in memberships_generator - for member in page["legalHoldMemberships"] - ] + if state.profile.api_client_auth == "True": + memberships = [member for page in memberships_generator for member in page] + else: + memberships = [ + member + for page in memberships_generator + for member in page["legalHoldMemberships"] + ] return memberships -def _get_all_active_matters(sdk): - matters_generator = sdk.legalhold.get_all_matters() - matters = [ - matter - for page in matters_generator - for matter in page["legalHolds"] - if matter["active"] - ] - for matter in matters: - matter["creator_username"] = matter["creator"]["username"] +def _get_all_active_matters(state): + matters_generator = state.sdk.legalhold.get_all_matters() + if state.profile.api_client_auth == "True": + matters = [ + matter for page in matters_generator for matter in page if matter["active"] + ] + for matter in matters: + try: + matter["creator_username"] = matter["creator"]["user"]["email"] + except KeyError: + pass + else: + matters = [ + matter + for page in matters_generator + for matter in page["legalHolds"] + if matter["active"] + ] + for matter in matters: + matter["creator_username"] = matter["creator"]["username"] return matters diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index 35b7744f9..b248501d7 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -5,6 +5,7 @@ from click import secho import code42cli.profile as cliprofile +from code42cli.click_ext.options import incompatible_with from code42cli.click_ext.types import PromptChoice from code42cli.click_ext.types import TOTP from code42cli.errors import Code42CLIError @@ -58,12 +59,14 @@ def username_option(required=False): "-u", "--username", required=required, + cls=incompatible_with(["api_client_id", "secret"]), help="The username of the Code42 API user.", ) password_option = click.option( "--password", + cls=incompatible_with(["api_client_id", "secret"]), help="The password for the Code42 API user. If this option is omitted, interactive prompts " "will be used to obtain the password.", ) @@ -84,18 +87,44 @@ def username_option(required=False): ) +def api_client_id_option(required=False): + return click.option( + "--api-client-id", + required=required, + cls=incompatible_with(["username", "password", "totp"]), + help="The API client key for API client authentication. Used with the `--secret` option.", + ) + + +def secret_option(required=False): + return click.option( + "--secret", + required=required, + cls=incompatible_with(["username", "password", "totp"]), + help="The API secret for API client authentication. Used with the `--api-client` option.", + ) + + @profile.command() @profile_name_arg() def show(profile_name): """Print the details of a profile.""" c42profile = cliprofile.get_profile(profile_name) echo(f"\n{c42profile.name}:") - echo(f"\t* username = {c42profile.username}") + if c42profile.api_client_auth == "True": + echo(f"\t* api-client-id = {c42profile.username}") + else: + echo(f"\t* username = {c42profile.username}") echo(f"\t* authority url = {c42profile.authority_url}") echo(f"\t* ignore-ssl-errors = {c42profile.ignore_ssl_errors}") echo(f"\t* use-v2-file-events = {c42profile.use_v2_file_events}") - if cliprofile.get_stored_password(c42profile.name) is not None: - echo("\t* A password is set.") + echo(f"\t* api-client-auth-profile = {c42profile.api_client_auth}") + if c42profile.api_client_auth == "True": + if cliprofile.get_stored_password(c42profile.name) is not None: + echo("\t* The API client secret is set.") + else: + if cliprofile.get_stored_password(c42profile.name) is not None: + echo("\t* A password is set.") echo("") echo("") @@ -120,28 +149,75 @@ def create( debug, totp, ): - """Create profile settings. The first profile created will be the default.""" + """ + Create a profile with username/password authentication. + The first profile created will be the default. + """ cliprofile.create_profile( - name, server, username, disable_ssl_errors, use_v2_file_events + name, + server, + username, + disable_ssl_errors, + use_v2_file_events, + api_client_auth=False, ) - password = password or _prompt_for_password(name) + password = password or _prompt_for_password() if password: - _set_pw(name, password, debug, totp=totp) + _set_pw(name, password, debug, totp=totp, api_client=False) + echo(f"Successfully created profile '{name}'.") + + +@profile.command() +@name_option(required=True) +@server_option(required=True) +@api_client_id_option(required=True) +@secret_option(required=True) +@yes_option(hidden=True) +@disable_ssl_option +@use_v2_file_events_option +@debug_option +def create_api_client( + name, + server, + api_client_id, + secret, + disable_ssl_errors, + use_v2_file_events, + debug, +): + """ + Create a profile with Code42 API client authentication. + The first profile created will be the default. + """ + cliprofile.create_profile( + name, + server, + api_client_id, + disable_ssl_errors, + use_v2_file_events, + api_client_auth=True, + ) + _set_pw(name, secret, debug, totp=False, api_client=True) echo(f"Successfully created profile '{name}'.") @profile.command() @name_option() @server_option() +@api_client_id_option() +@secret_option() @username_option() @password_option @totp_option @disable_ssl_option @use_v2_file_events_option +@yes_option(hidden=True) @debug_option def update( name, server, + api_client_id, + secret, username, password, disable_ssl_errors, @@ -152,24 +228,102 @@ def update( """Update an existing profile.""" c42profile = cliprofile.get_profile(name) - if ( - not server - and not username - and not password - and disable_ssl_errors is None - and use_v2_file_events is None - ): - raise click.UsageError( - "Must provide at least one of `--username`, `--server`, `--password`, `--use-v2-file-events` or " - "`--disable-ssl-errors` when updating a profile." - ) - cliprofile.update_profile( - c42profile.name, server, username, disable_ssl_errors, use_v2_file_events - ) - if not password and not c42profile.has_stored_password: - password = _prompt_for_password(c42profile.name) - if password: - _set_pw(name, password, debug, totp=totp) + if c42profile.api_client_auth == "True": + if not any( + [ + server, + api_client_id, + secret, + disable_ssl_errors is not None, + use_v2_file_events is not None, + ] + ): + raise click.UsageError( + "Must provide at least one of `--server`, `--api-client-id`, `--secret`, `--use-v2-file-events` or " + "`--disable-ssl-errors` when updating an API client profile. " + "Provide both `--username` and `--password` options to switch this profile to username/password authentication." + ) + if (username and not password) or (password and not username): + raise click.UsageError( + "This profile currently uses API client authentication. " + "Please provide both the `--username` and `--password` options to update this profile to use username/password authentication." + ) + elif username and password: + if does_user_agree( + "You passed the `--username` and `--password options for a profile currently using Code42 API client authentication. " + "Are you sure you would like to update this profile to use username/password authentication? This will overwrite existing credentials. (y/n): " + ): + cliprofile.update_profile( + c42profile.name, + server, + username, + disable_ssl_errors, + use_v2_file_events, + api_client_auth=False, + ) + _set_pw(c42profile.name, password, debug, api_client=False) + else: + echo(f"Profile '{c42profile.name}` was not updated.") + return + else: + cliprofile.update_profile( + c42profile.name, + server, + api_client_id, + disable_ssl_errors, + use_v2_file_events, + ) + if secret: + _set_pw(c42profile.name, secret, debug, api_client=True) + + else: + if ( + not server + and not username + and not password + and disable_ssl_errors is None + and use_v2_file_events is None + ): + raise click.UsageError( + "Must provide at least one of `--server`, `--username`, `--password`, `--use-v2-file-events` or " + "`--disable-ssl-errors` when updating a username/password authenticated profile. " + "Provide both `--api-client-id` and `--secret` options to switch this profile to Code42 API client authentication." + ) + if (api_client_id and not secret) or (api_client_id and not secret): + raise click.UsageError( + "This profile currently uses username/password authentication. " + "Please provide both the `--api-client-id` and `--secret` options to update this profile to use Code42 API client authentication." + ) + elif api_client_id and secret: + if does_user_agree( + "You passed the `--api-client-id` and `--secret options for a profile currently using username/password authentication. " + "Are you sure you would like to update this profile to use Code42 API client authentication? This will overwrite existing credentials. (y/n): " + ): + cliprofile.update_profile( + c42profile.name, + server, + api_client_id, + disable_ssl_errors, + use_v2_file_events, + api_client_auth=True, + ) + _set_pw(c42profile.name, secret, debug, api_client=True) + else: + echo(f"Profile '{name}` was not updated.") + return + else: + cliprofile.update_profile( + c42profile.name, + server, + username, + disable_ssl_errors, + use_v2_file_events, + ) + if not password and not c42profile.has_stored_password: + password = _prompt_for_password() + + if password: + _set_pw(c42profile.name, password, debug, totp=totp) echo(f"Profile '{c42profile.name}' has been updated.") @@ -251,16 +405,22 @@ def delete_all(): echo("\nNo profiles exist. Nothing to delete.") -def _prompt_for_password(profile_name): +def _prompt_for_password(): if does_user_agree("Would you like to set a password? (y/n): "): password = getpass() return password -def _set_pw(profile_name, password, debug, totp=None): +def _set_pw(profile_name, password, debug, totp=None, api_client=False): c42profile = cliprofile.get_profile(profile_name) try: - create_sdk(c42profile, is_debug_mode=debug, password=password, totp=totp) + create_sdk( + c42profile, + is_debug_mode=debug, + password=password, + totp=totp, + api_client=api_client, + ) except Exception: secho("Password not stored!", bold=True) raise diff --git a/src/code42cli/config.py b/src/code42cli/config.py index 769cdbad4..f989d8f27 100644 --- a/src/code42cli/config.py +++ b/src/code42cli/config.py @@ -20,6 +20,7 @@ class ConfigAccessor: USERNAME_KEY = "c42_username" IGNORE_SSL_ERRORS_KEY = "ignore-ssl-errors" USE_V2_FILE_EVENTS_KEY = "use-v2-file-events" + API_CLIENT_AUTH_KEY = "api-client-auth" DEFAULT_PROFILE = "default_profile" _INTERNAL_SECTION = "Internal" @@ -39,10 +40,10 @@ def get_profile(self, name=None): If the name does not exist or there is no existing profile, it will throw an exception. """ name = name or self._default_profile_name - if name not in self._get_sections() or name == self.DEFAULT_VALUE: + if name not in self.parser.sections() or name == self.DEFAULT_VALUE: name = name if name != self.DEFAULT_VALUE else None raise NoConfigProfileError(name) - return self._get_profile(name) + return self.parser[name] def get_all_profiles(self): """Returns all the available profiles.""" @@ -53,7 +54,13 @@ def get_all_profiles(self): return profiles def create_profile( - self, name, server, username, ignore_ssl_errors, use_v2_file_events + self, + name, + server, + username, + ignore_ssl_errors, + use_v2_file_events, + api_client_auth, ): """Creates a new profile if one does not already exist for that name.""" try: @@ -66,7 +73,12 @@ def create_profile( profile = self.get_profile(name) self.update_profile( - profile.name, server, username, ignore_ssl_errors, use_v2_file_events + profile.name, + server, + username, + ignore_ssl_errors, + use_v2_file_events, + api_client_auth, ) self._try_complete_setup(profile) @@ -77,16 +89,19 @@ def update_profile( username=None, ignore_ssl_errors=None, use_v2_file_events=None, + api_client_auth=None, ): profile = self.get_profile(name) if server: - self._set_authority_url(server, profile) + profile[self.AUTHORITY_KEY] = server.strip() if username: - self._set_username(username, profile) + profile[self.USERNAME_KEY] = username.strip() if ignore_ssl_errors is not None: - self._set_ignore_ssl_errors(ignore_ssl_errors, profile) + profile[self.IGNORE_SSL_ERRORS_KEY] = str(ignore_ssl_errors) if use_v2_file_events is not None: - self._set_use_v2_file_events(use_v2_file_events, profile) + profile[self.USE_V2_FILE_EVENTS_KEY] = str(use_v2_file_events) + if api_client_auth is not None: + profile[self.API_CLIENT_AUTH_KEY] = str(api_client_auth) self._save() def switch_default_profile(self, new_default_name): @@ -105,24 +120,6 @@ def delete_profile(self, name): self._internal[self.DEFAULT_PROFILE] = self.DEFAULT_VALUE self._save() - def _set_authority_url(self, new_value, profile): - profile[self.AUTHORITY_KEY] = new_value.strip() - - def _set_username(self, new_value, profile): - profile[self.USERNAME_KEY] = new_value.strip() - - def _set_ignore_ssl_errors(self, new_value, profile): - profile[self.IGNORE_SSL_ERRORS_KEY] = str(new_value) - - def _set_use_v2_file_events(self, new_value, profile): - profile[self.USE_V2_FILE_EVENTS_KEY] = str(new_value) - - def _get_sections(self): - return self.parser.sections() - - def _get_profile(self, name): - return self.parser[name] - @property def _internal(self): return self.parser[self._INTERNAL_SECTION] @@ -132,7 +129,7 @@ def _default_profile_name(self): return self._internal[self.DEFAULT_PROFILE] def _get_profile_names(self): - names = list(self._get_sections()) + names = list(self.parser.sections()) names.remove(self._INTERNAL_SECTION) return names @@ -148,6 +145,7 @@ def _create_profile_section(self, name): self.parser[name][self.USERNAME_KEY] = self.DEFAULT_VALUE self.parser[name][self.IGNORE_SSL_ERRORS_KEY] = str(False) self.parser[name][self.USE_V2_FILE_EVENTS_KEY] = str(False) + self.parser[name][self.API_CLIENT_AUTH_KEY] = str(False) def _save(self): with open(self.path, "w+", encoding="utf-8") as file: diff --git a/src/code42cli/options.py b/src/code42cli/options.py index 0724b075f..7247d9b17 100644 --- a/src/code42cli/options.py +++ b/src/code42cli/options.py @@ -61,7 +61,12 @@ def profile(self, value): @property def sdk(self): if self._sdk is None: - self._sdk = create_sdk(self.profile, self.debug, totp=self.totp) + self._sdk = create_sdk( + self.profile, + self.debug, + totp=self.totp, + api_client=self.profile.api_client_auth == "True", + ) return self._sdk def set_assume_yes(self, param): diff --git a/src/code42cli/profile.py b/src/code42cli/profile.py index b3621d661..8a191e669 100644 --- a/src/code42cli/profile.py +++ b/src/code42cli/profile.py @@ -32,6 +32,10 @@ def ignore_ssl_errors(self): def use_v2_file_events(self): return self._profile.get(ConfigAccessor.USE_V2_FILE_EVENTS_KEY) + @property + def api_client_auth(self): + return self._profile.get(ConfigAccessor.API_CLIENT_AUTH_KEY) + @property def has_stored_password(self): stored_password = password.get_stored_password(self) @@ -103,11 +107,13 @@ def switch_default_profile(profile_name): config_accessor.switch_default_profile(profile.name) -def create_profile(name, server, username, ignore_ssl_errors, use_v2_file_events): +def create_profile( + name, server, username, ignore_ssl_errors, use_v2_file_events, api_client_auth +): if profile_exists(name): raise Code42CLIError(f"A profile named '{name}' already exists.") config_accessor.create_profile( - name, server, username, ignore_ssl_errors, use_v2_file_events + name, server, username, ignore_ssl_errors, use_v2_file_events, api_client_auth ) @@ -122,9 +128,11 @@ def delete_profile(profile_name): config_accessor.delete_profile(profile_name) -def update_profile(name, server, username, ignore_ssl_errors, use_v2_file_events): +def update_profile( + name, server, username, ignore_ssl_errors, use_v2_file_events, api_client_auth=None +): config_accessor.update_profile( - name, server, username, ignore_ssl_errors, use_v2_file_events + name, server, username, ignore_ssl_errors, use_v2_file_events, api_client_auth ) @@ -145,13 +153,25 @@ def set_password(new_password, profile_name=None): password.set_password(profile, new_password) -CREATE_PROFILE_HELP = "\nTo add a profile, use:\n{}".format( - style( - "\tcode42 profile create " - "--name " - "--server " - "--username \n", - bold=True, +CREATE_PROFILE_HELP = ( + "\nTo add a profile with username/password authentication, use:\n{}".format( + style( + "\tcode42 profile create " + "--name " + "--server " + "--username \n", + bold=True, + ) + ) + + "\nOr to add a profile with API client authentication, use:\n{}".format( + style( + "\tcode42 profile create-api-client " + "--name " + "--server " + "--api-client-id " + "--secret \n", + bold=True, + ) ) ) diff --git a/src/code42cli/sdk_client.py b/src/code42cli/sdk_client.py index aa3d7aefc..7fd2b5799 100644 --- a/src/code42cli/sdk_client.py +++ b/src/code42cli/sdk_client.py @@ -20,7 +20,7 @@ logger = get_main_cli_logger() -def create_sdk(profile, is_debug_mode, password=None, totp=None): +def create_sdk(profile, is_debug_mode, password=None, totp=None, api_client=False): proxy = environ.get("HTTPS_PROXY") or environ.get("https_proxy") if proxy: py42.settings.proxies = {"https": proxy} @@ -38,11 +38,17 @@ def create_sdk(profile, is_debug_mode, password=None, totp=None): ) py42.settings.verify_ssl_certs = False password = password or profile.get_password() - return _validate_connection(profile.authority_url, profile.username, password, totp) + return _validate_connection( + profile.authority_url, profile.username, password, totp, api_client + ) -def _validate_connection(authority_url, username, password, totp=None): +def _validate_connection( + authority_url, username, password, totp=None, api_client=False +): try: + if api_client: + return py42.sdk.from_api_client(authority_url, username, password) return py42.sdk.from_local_account(authority_url, username, password, totp=totp) except SSLError as err: logger.log_error(err) diff --git a/tests/cmds/test_profile.py b/tests/cmds/test_profile.py index 474a76090..859345d41 100644 --- a/tests/cmds/test_profile.py +++ b/tests/cmds/test_profile.py @@ -102,7 +102,7 @@ def test_create_profile_if_user_sets_password_is_created( ], ) mock_cliprofile_namespace.create_profile.assert_called_once_with( - "foo", "bar", "baz", True, None + "foo", "bar", "baz", True, None, api_client_auth=False ) @@ -128,7 +128,7 @@ def test_create_profile_if_user_does_not_set_password_is_created( ], ) mock_cliprofile_namespace.create_profile.assert_called_once_with( - "foo", "bar", "baz", True, True + "foo", "bar", "baz", True, True, api_client_auth=False ) @@ -247,6 +247,34 @@ def test_create_profile_outputs_confirmation( assert "Successfully created profile 'foo'." in result.output +def test_create_api_client_profile_with_api_client_id_and_secret_creates_profile( + runner, mock_cliprofile_namespace, valid_connection, profile +): + mock_cliprofile_namespace.profile_exists.return_value = False + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke( + cli, + [ + "profile", + "create-api-client", + "-n", + "foo", + "-s", + "bar", + "--api-client-id", + "baz", + "--secret", + "fob", + "--disable-ssl-errors", + "True", + ], + ) + mock_cliprofile_namespace.create_profile.assert_called_once_with( + "foo", "bar", "baz", True, None, api_client_auth=True + ) + assert "Successfully created profile 'foo'." in result.output + + def test_update_profile_updates_existing_profile( runner, mock_cliprofile_namespace, user_agreement, valid_connection, profile ): @@ -401,13 +429,119 @@ def test_update_profile_when_given_zero_args_prints_error_message( mock_cliprofile_namespace.get_profile.return_value = profile result = runner.invoke(cli, ["profile", "update"]) expected = ( - "Must provide at least one of `--username`, `--server`, `--password`, " - "`--use-v2-file-events` or `--disable-ssl-errors` when updating a profile." + "Must provide at least one of `--server`, `--username`, `--password`, " + "`--use-v2-file-events` or `--disable-ssl-errors` when updating a username/password authenticated profile." ) assert "Profile 'foo' has been updated" not in result.output assert expected in result.output +def test_update_profile_when_api_client_authentication_and_is_given_zero_args_prints_error_message( + runner, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + profile.api_client_auth = "True" + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke(cli, ["profile", "update"]) + expected = ( + "Must provide at least one of `--server`, `--api-client-id`, `--secret`, `--use-v2-file-events` or " + "`--disable-ssl-errors` when updating an API client profile." + ) + assert "Profile 'foo' has been updated" not in result.output + assert expected in result.output + + +def test_update_profile_when_api_client_authentication_updates_existing_profile( + runner, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + profile.api_client_auth = "True" + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke( + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "--api-client-id", + "baz", + "--use-v2-file-events", + "True", + ], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, "bar", "baz", None, True + ) + assert "Profile 'foo' has been updated" in result.output + + +def test_update_profile_when_updating_auth_profile_to_api_client_updates_existing_profile( + runner, valid_connection, mock_cliprofile_namespace, profile +): + name = "foo" + profile.name = name + profile.api_client_auth = "False" + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke( + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "--api-client-id", + "baz", + "--secret", + "fob", + "--use-v2-file-events", + "True", + "-y", + ], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, "bar", "baz", None, True, api_client_auth=True + ) + assert "Profile 'foo' has been updated" in result.output + + +def test_update_profile_when_updating_api_client_profile_to_user_credentails_updates_existing_profile( + runner, mock_cliprofile_namespace, profile, valid_connection +): + name = "foo" + profile.name = name + profile.api_client_auth = "True" + mock_cliprofile_namespace.get_profile.return_value = profile + result = runner.invoke( + cli, + [ + "profile", + "update", + "-n", + name, + "-s", + "bar", + "-u", + "baz", + "--password", + "fob", + "--use-v2-file-events", + "True", + "-y", + ], + ) + mock_cliprofile_namespace.update_profile.assert_called_once_with( + name, "bar", "baz", None, True, api_client_auth=False + ) + assert "Profile 'foo' has been updated" in result.output + + def test_delete_profile_warns_if_deleting_default(runner, mock_cliprofile_namespace): mock_cliprofile_namespace.is_default_profile.return_value = True result = runner.invoke(cli, ["profile", "delete", "mockdefault"]) diff --git a/tests/conftest.py b/tests/conftest.py index 7e809eb85..b609cacb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,13 +87,18 @@ def alert_namespace(): def create_profile_values_dict( - authority=None, username=None, ignore_ssl=False, use_v2_file_events=False + authority=None, + username=None, + ignore_ssl=False, + use_v2_file_events=False, + api_client_auth="False", ): return { ConfigAccessor.AUTHORITY_KEY: "example.com", ConfigAccessor.USERNAME_KEY: "foo", - ConfigAccessor.IGNORE_SSL_ERRORS_KEY: True, - ConfigAccessor.USE_V2_FILE_EVENTS_KEY: False, + ConfigAccessor.IGNORE_SSL_ERRORS_KEY: "True", + ConfigAccessor.USE_V2_FILE_EVENTS_KEY: "False", + ConfigAccessor.API_CLIENT_AUTH_KEY: "False", } diff --git a/tests/test_config.py b/tests/test_config.py index 89ee271e8..2213e4e6a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -153,7 +153,7 @@ def test_create_profile_when_given_default_name_does_not_create( accessor = ConfigAccessor(config_parser_for_create) with pytest.raises(Exception): accessor.create_profile( - ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False, False + ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False, False, False ) def test_create_profile_when_no_default_profile_sets_default( @@ -165,7 +165,9 @@ def test_create_profile_when_no_default_profile_sets_default( accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) + accessor.create_profile( + _TEST_PROFILE_NAME, "example.com", "bar", None, None, None + ) assert accessor.switch_default_profile.call_count == 1 def test_create_profile_when_has_default_profile_does_not_set_default( @@ -177,7 +179,9 @@ def test_create_profile_when_has_default_profile_does_not_set_default( accessor = ConfigAccessor(config_parser_for_create) accessor.switch_default_profile = mocker.MagicMock() - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) + accessor.create_profile( + _TEST_PROFILE_NAME, "example.com", "bar", None, None, None + ) assert not accessor.switch_default_profile.call_count def test_create_profile_when_not_existing_saves( @@ -188,7 +192,9 @@ def test_create_profile_when_not_existing_saves( setup_parser_one_profile(mock_internal, mock_internal, config_parser_for_create) accessor = ConfigAccessor(config_parser_for_create) - accessor.create_profile(_TEST_PROFILE_NAME, "example.com", "bar", None, None) + accessor.create_profile( + _TEST_PROFILE_NAME, "example.com", "bar", None, None, None + ) assert mock_saver.call_count def test_update_profile_when_no_profile_exists_raises_exception( diff --git a/tests/test_profile.py b/tests/test_profile.py index 95164d484..d84813918 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -64,7 +64,15 @@ def test_username_returns_expected_value(self): def test_ignore_ssl_errors_returns_expected_value(self): mock_profile = create_mock_profile() - assert mock_profile.ignore_ssl_errors + assert mock_profile.ignore_ssl_errors == "True" + + def test_use_v2_file_events_returns_expected_value(self): + mock_profile = create_mock_profile() + assert mock_profile.use_v2_file_events == "False" + + def test_api_client_auth_returns_expected_value(self): + mock_profile = create_mock_profile() + assert mock_profile.api_client_auth == "False" def test_get_profile_returns_expected_profile(config_accessor): @@ -132,17 +140,33 @@ def test_switch_default_profile_switches_to_expected_profile(config_accessor): config_accessor.switch_default_profile.assert_called_once_with("switchtome") -def test_create_profile_uses_expected_profile_values(config_accessor): +def test_create_profile_when_user_credentials_uses_expected_profile_values( + config_accessor, +): config_accessor.get_profile.side_effect = NoConfigProfileError() profile_name = "profilename" server = "server" username = "username" ssl_errors_disabled = True cliprofile.create_profile( - profile_name, server, username, ssl_errors_disabled, False + profile_name, server, username, ssl_errors_disabled, False, False + ) + config_accessor.create_profile.assert_called_once_with( + profile_name, server, username, ssl_errors_disabled, False, False + ) + + +def test_create_profile_when_api_client_uses_expected_profile_values(config_accessor): + config_accessor.get_profile.side_effect = NoConfigProfileError() + profile_name = "profilename" + server = "server" + api_client_id = "key-42" + ssl_errors_disabled = True + cliprofile.create_profile( + profile_name, server, api_client_id, ssl_errors_disabled, False, True ) config_accessor.create_profile.assert_called_once_with( - profile_name, server, username, ssl_errors_disabled, False + profile_name, server, api_client_id, ssl_errors_disabled, False, True ) @@ -151,7 +175,7 @@ def test_create_profile_if_profile_exists_exits( ): config_accessor.get_profile.return_value = mocker.MagicMock() with pytest.raises(Code42CLIError): - cliprofile.create_profile("foo", "bar", "baz", True, False) + cliprofile.create_profile("foo", "bar", "baz", True, False, False) def test_get_all_profiles_returns_expected_profile_list(config_accessor): diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index d297e960b..07f795a1d 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -27,6 +27,11 @@ def mock_sdk_factory(mocker): return mocker.patch("py42.sdk.from_local_account") +@pytest.fixture +def mock_api_client_sdk_factory(mocker): + return mocker.patch("py42.sdk.from_api_client") + + @pytest.fixture def mock_profile_with_password(): profile = create_mock_profile() @@ -154,6 +159,20 @@ def test_create_sdk_connection_when_mfa_token_invalid_raises_expected_cli_error( assert str(err.value) == "Invalid credentials or TOTP token for user foo." +def test_create_sdk_connection_when_using_api_client_credentials_uses_api_client_function( + mock_api_client_sdk_factory, mock_profile_with_password +): + create_sdk( + mock_profile_with_password, + False, + password="api-client-secret-42", + api_client=True, + ) + mock_api_client_sdk_factory.assert_called_once_with( + "example.com", "foo", "api-client-secret-42" + ) + + def test_totp_option_when_passed_is_passed_to_sdk_initialization( mocker, profile, runner ): From b52aade7a3acb6b1dc5f232d81746836bccfd700 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Thu, 6 Oct 2022 11:06:30 -0500 Subject: [PATCH 329/349] bugix/update-profile-option-checking (#390) --- docs/userguides/profile.md | 2 +- src/code42cli/cmds/profile.py | 42 +++++++++++++++++------------------ 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/docs/userguides/profile.md b/docs/userguides/profile.md index da9dcac40..99995c95f 100644 --- a/docs/userguides/profile.md +++ b/docs/userguides/profile.md @@ -21,7 +21,7 @@ time you run a command. Once you've generated an API Client in your Code42 console, use the following command to create your profile with API client authentication: ```bash -code42 profile create-api-client --name MY_API_CLIENT_PROFILE --server example.authority.com --api-client-id "key-42" --secret "code42%api%client%secret" +code42 profile create-api-client --name MY_API_CLIENT_PROFILE --server example.authority.com --api-client-id 'key-42' --secret 'code42%api%client%secret' ``` ```{eval-rst} diff --git a/src/code42cli/cmds/profile.py b/src/code42cli/cmds/profile.py index b248501d7..8c0b1567f 100644 --- a/src/code42cli/cmds/profile.py +++ b/src/code42cli/cmds/profile.py @@ -228,21 +228,31 @@ def update( """Update an existing profile.""" c42profile = cliprofile.get_profile(name) - if c42profile.api_client_auth == "True": - if not any( - [ - server, - api_client_id, - secret, - disable_ssl_errors is not None, - use_v2_file_events is not None, - ] - ): + if not any( + [ + server, + api_client_id, + secret, + username, + password, + disable_ssl_errors is not None, + use_v2_file_events is not None, + ] + ): + if c42profile.api_client_auth == "True": raise click.UsageError( "Must provide at least one of `--server`, `--api-client-id`, `--secret`, `--use-v2-file-events` or " "`--disable-ssl-errors` when updating an API client profile. " "Provide both `--username` and `--password` options to switch this profile to username/password authentication." ) + else: + raise click.UsageError( + "Must provide at least one of `--server`, `--username`, `--password`, `--use-v2-file-events` or " + "`--disable-ssl-errors` when updating a username/password authenticated profile. " + "Provide both `--api-client-id` and `--secret` options to switch this profile to Code42 API client authentication." + ) + + if c42profile.api_client_auth == "True": if (username and not password) or (password and not username): raise click.UsageError( "This profile currently uses API client authentication. " @@ -277,18 +287,6 @@ def update( _set_pw(c42profile.name, secret, debug, api_client=True) else: - if ( - not server - and not username - and not password - and disable_ssl_errors is None - and use_v2_file_events is None - ): - raise click.UsageError( - "Must provide at least one of `--server`, `--username`, `--password`, `--use-v2-file-events` or " - "`--disable-ssl-errors` when updating a username/password authenticated profile. " - "Provide both `--api-client-id` and `--secret` options to switch this profile to Code42 API client authentication." - ) if (api_client_id and not secret) or (api_client_id and not secret): raise click.UsageError( "This profile currently uses username/password authentication. " From c4bbdb0589e0d7088b4f3a3d45dfb464893a25bc Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Thu, 6 Oct 2022 12:50:13 -0500 Subject: [PATCH 330/349] prep 1.16.0 release (#391) --- CHANGELOG.md | 13 ++++++++----- src/code42cli/__version__.py | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f13371670..7b8e95d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,22 +8,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.16.0 - 2022-10-06 ### Added - Support for Code42 API clients. - You can create a new profile with API client authentication using `code42 profile create-api-client` - Or, update your existing profile to use API clients with `code42 update --api-client-id --secret ` -- When using API client authentication, changes to the following `legal-hold` commands: - - `code42 legal-hold list` - Change in response shape. - - `code42 legal-hold show` - Change in response shape. - - `code42 legal-hold search-events` - **Not available.** - New commands to view details for user risk profiles: - `code42 users list-risk-profiles` - `code42 users show-risk-profile` - Proxy support via `HTTPS_PROXY` environment variable. +### Changed + +- **When using API client authentication**, changes to the following `legal-hold` commands: + - `code42 legal-hold list` - Change in response shape. + - `code42 legal-hold show` - Change in response shape. + - `code42 legal-hold search-events` - **Not available.** + ## 1.15.0 - 2022-08-23 ### Added diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 6b0872cb2..638c1217d 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.15.0" +__version__ = "1.16.0" From bac23a311b1e1cb0f33e562f4188096690a17c34 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Mon, 10 Oct 2022 15:39:59 -0500 Subject: [PATCH 331/349] chore/update-click-8.0 (#392) * chore/update-click-8.0 * remove click version upper limit * test for both click <8 and >=8 error messages * prep for release --- CHANGELOG.md | 6 ++++++ docs/requirements.txt | 2 +- setup.py | 2 +- src/code42cli/__version__.py | 2 +- tests/cmds/test_departing_employee.py | 7 +++++++ tests/cmds/test_securitydata.py | 9 +++++++-- tests/cmds/test_trustedactivities.py | 7 +++++-- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8e95d2d..c282d3adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.16.1 - 2022-10-10 + +### Added + +- Support for `click` version `>=8.0.0`. + ## 1.16.0 - 2022-10-06 ### Added diff --git a/docs/requirements.txt b/docs/requirements.txt index 37ab4af69..4fffce759 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -click==7.1.2 +click==8.0.0 sphinx-click==2.5.0 diff --git a/setup.py b/setup.py index c123e5b6e..362119d11 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ python_requires=">=3.6.2, <4", install_requires=[ "chardet", - "click>=7.1.1, <8", + "click==7.1.1", "click_plugins>=1.1.1", "colorama>=0.4.3", "keyring==18.0.1", diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 638c1217d..d38802290 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.16.0" +__version__ = "1.16.1" diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py index feafddd64..d682709b4 100644 --- a/tests/cmds/test_departing_employee.py +++ b/tests/cmds/test_departing_employee.py @@ -353,6 +353,7 @@ def test_remove_bulk_users_uses_expected_arguments_when_flat_file( def test_add_departing_employee_when_invalid_date_validation_raises_error( runner, cli_state_with_user ): + # day is out of range for month departure_date = "2020-02-30" result = runner.invoke( cli, @@ -367,6 +368,9 @@ def test_add_departing_employee_when_invalid_date_validation_raises_error( ) assert result.exit_code == 2 assert ( + "Invalid value for '--departure-date': '2020-02-30' does not match the format '%Y-%m-%d'" + in result.output # invalid datetime format + ) or ( "Invalid value for '--departure-date': invalid datetime format" in result.output ) @@ -388,6 +392,9 @@ def test_add_departing_employee_when_invalid_date_format_validation_raises_error ) assert result.exit_code == 2 assert ( + "Invalid value for '--departure-date': '2020-30-01' does not match the format '%Y-%m-%d'" + in result.output + ) or ( "Invalid value for '--departure-date': invalid datetime format" in result.output ) diff --git a/tests/cmds/test_securitydata.py b/tests/cmds/test_securitydata.py index b41e16fc9..a062be4d6 100644 --- a/tests/cmds/test_securitydata.py +++ b/tests/cmds/test_securitydata.py @@ -326,7 +326,10 @@ def test_search_and_send_to_when_advanced_query_passed_non_existent_filename_rai cli, [*command, "--advanced-query", "@not_a_file"], obj=cli_state ) assert result.exit_code == 2 - assert "Could not open file: not_a_file" in result.stdout + assert ( + " Invalid value for '--advanced-query': 'not_a_file': No such file or directory" + in result.stdout + ) or ("Could not open file: not_a_file" in result.stdout) @search_and_send_to_test @@ -724,7 +727,9 @@ def test_search_and_send_to_when_given_invalid_exposure_type_causes_exit( obj=cli_state, ) assert result.exit_code == 2 - assert "invalid choice: NotValid" in result.output + assert ( + "Invalid value" in result.output or "invalid choice: NotValid" in result.output + ) @search_and_send_to_test diff --git a/tests/cmds/test_trustedactivities.py b/tests/cmds/test_trustedactivities.py index df27af188..4efe57666 100644 --- a/tests/cmds/test_trustedactivities.py +++ b/tests/cmds/test_trustedactivities.py @@ -38,7 +38,7 @@ """ MISSING_ARGUMENT_ERROR = "Missing argument '{}'." -MISSING_TYPE = MISSING_ARGUMENT_ERROR.format("[DOMAIN|SLACK]") +MISSING_TYPE = MISSING_ARGUMENT_ERROR.format("{DOMAIN|SLACK}") MISSING_VALUE = MISSING_ARGUMENT_ERROR.format("VALUE") MISSING_RESOURCE_ID_ARG = MISSING_ARGUMENT_ERROR.format("RESOURCE_ID") RESOURCE_ID_NOT_FOUND_ERROR = "Resource ID '{}' not found." @@ -114,7 +114,10 @@ def test_create_when_missing_type_prints_error(runner, cli_state): command = ["trusted-activities", "create", "--description", "description"] result = runner.invoke(cli, command, obj=cli_state) assert result.exit_code == 2 - assert MISSING_TYPE in result.output + assert ( + MISSING_TYPE in result.output + or MISSING_ARGUMENT_ERROR.format("[DOMAIN|SLACK]") in result.output + ) def test_create_when_missing_value_prints_error(runner, cli_state): From ce7258d990e748a7a098a3f367d98689fddf57ab Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Mon, 7 Nov 2022 10:44:42 -0600 Subject: [PATCH 332/349] update install req for click (#393) --- CHANGELOG.md | 6 ++++++ setup.py | 2 +- src/code42cli/__version__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c282d3adc..8f5f8be7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.16.2 - 2022-11-07 + +### Fixed + +- Updated setup requirements to allow for install with any `click` version `>=7.1.1` + ## 1.16.1 - 2022-10-10 ### Added diff --git a/setup.py b/setup.py index 362119d11..cdf146a04 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ python_requires=">=3.6.2, <4", install_requires=[ "chardet", - "click==7.1.1", + "click>=7.1.1", "click_plugins>=1.1.1", "colorama>=0.4.3", "keyring==18.0.1", diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index d38802290..9e54f289b 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.16.1" +__version__ = "1.16.2" From a43d6128e742507f6591e5d76c204b151e14e653 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Mon, 5 Dec 2022 09:09:43 -0600 Subject: [PATCH 333/349] drop 3.6 and add 3.10/3.11 to python version matrix (#394) * drop 3.6 and add 3.10/3.11 to python version matrix * use strings for version numbers * update tox.ini and pytest* versions * put back verbose flag * use flake8 github repo * try alternate CLA assistant --- .github/workflows/build.yml | 2 +- .github/workflows/cla.yml | 6 +++--- .github/workflows/nightly.yml | 2 +- .pre-commit-config.yaml | 2 +- tox.ini | 14 +++++++------- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 688324d19..472064a13 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.6, 3.7, 3.8, 3.9] + python: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index d39ed40fb..69f0e6386 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -12,17 +12,17 @@ jobs: - name: "CLA Assistant" if: (github.event.comment.body == 'recheckcla' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' # Alpha Release - uses: cla-assistant/cla-assistant@v2.13.0 + uses: contributor-assistant/github-action@v2.2.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} with: path-to-signatures: '.cla_signatures.json' - path-to-cla-document: 'https://code42.github.io/code42-cla/Code42_Individual_Contributor_License_Agreement' + path-to-document: 'https://code42.github.io/code42-cla/Code42_Individual_Contributor_License_Agreement' # branch should not be protected branch: 'main' - allowlist: alang13,unparalleled-js,kiran-chaudhary,timabrmsn,ceciliastevens,DiscoRiver,annie-payseur,amoravec,patelsagar192,tora-kozic + allowlist: timabrmsn,ceciliastevens,annie-payseur,amoravec,tora-kozic #below are the optional inputs - If the optional inputs are not given, then default values will be taken #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index fd50ef5c9..ea8a73ffb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: [3.6, 3.7, 3.8] + python: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64d8f2b55..14e57479a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: rev: 22.3.0 hooks: - id: black - - repo: https://gitlab.com/pycqa/flake8 + - repo: https://github.com/pycqa/flake8 rev: 3.8.3 hooks: - id: flake8 diff --git a/tox.ini b/tox.ini index 9904dcbd2..9413bdf03 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,15 @@ [tox] envlist = - py{39,38,37,36} + py{311,310,39,38,37} docs style skip_missing_interpreters = true [testenv] deps = - pytest == 4.6.11 - pytest-mock == 2.0.0 - pytest-cov == 2.10.0 + pytest == 7.2.0 + pytest-mock == 3.10.0 + pytest-cov == 4.0.0 pandas >= 1.1.3 pexpect == 4.8.0 @@ -41,9 +41,9 @@ commands = pre-commit run --all-files --show-diff-on-failure [testenv:nightly] deps = - pytest == 4.6.11 - pytest-mock == 2.0.0 - pytest-cov == 2.10.0 + pytest == 7.2.0 + pytest-mock == 3.10.0 + pytest-cov == 4.0.0 git+https://github.com/code42/py42.git@main#egg=py42 [testenv:integration] From 24c11c962b7739a464232294a570549baf1fc77f Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 18 Jan 2023 09:23:48 -0600 Subject: [PATCH 334/349] fix legal hold/API client bug in devices command (#396) * fix bug in `devices list --include-legal-hold-membership` when using api client auth * black * skip new bugbear warning * version and changelog --- CHANGELOG.md | 6 ++ src/code42cli/__version__.py | 2 +- src/code42cli/cmds/devices.py | 20 ++++-- src/code42cli/output_formats.py | 4 +- tests/cmds/test_devices.py | 116 +++++++++++++++++++++++++++++--- tests/conftest.py | 2 +- 6 files changed, 133 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5f8be7c..944af8324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.16.3 - 2022-12-08 + +### Fixed + +- Bug in `devices list` command when using `--include-legal-hold-membership` option with an API client auth profile. + ## 1.16.2 - 2022-11-07 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 9e54f289b..6cc7415d1 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.16.2" +__version__ = "1.16.3" diff --git a/src/code42cli/cmds/devices.py b/src/code42cli/cmds/devices.py index 6793daed2..d635d1785 100644 --- a/src/code42cli/cmds/devices.py +++ b/src/code42cli/cmds/devices.py @@ -432,11 +432,21 @@ def _add_legal_hold_membership_to_device_dataframe(sdk, df): def _get_all_active_hold_memberships(sdk): for page in sdk.legalhold.get_all_matters(active=True): - for matter in page["legalHolds"]: - for _page in sdk.legalhold.get_all_matter_custodians( - legal_hold_uid=matter["legalHoldUid"], active=True - ): - yield from _page["legalHoldMemberships"] + if sdk._auth_flag == 1: # noqa: api client endpoint returns a list directly + matters = page.data + else: + matters = page["legalHolds"] + for matter in matters: + if sdk._auth_flag == 1: # noqa: api client endpoint returns a list directly + for _page in sdk.legalhold.get_all_matter_custodians( + legal_hold_matter_uid=matter["legalHoldUid"], active=True + ): + yield from _page.data + else: + for _page in sdk.legalhold.get_all_matter_custodians( + legal_hold_uid=matter["legalHoldUid"], active=True + ): + yield from _page["legalHoldMemberships"] def _get_device_dataframe( diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index be337235e..b0f87a259 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -138,7 +138,7 @@ def _iter_json(self, dfs, columns=None, **kwargs): yield f"{json_string}\n" def _checkpoint_and_iter_formatted_events(self, df, formatted_rows): - for event, row in zip(df.to_dict("records"), formatted_rows): + for event, row in zip(df.to_dict("records"), formatted_rows): # noqa: B905 yield row self.checkpoint_func(event) @@ -188,7 +188,7 @@ def iter_rows(self, dfs, columns=None): filtered = self._select_columns(df, columns) else: filtered = df - for full_event, filtered_event in zip( + for full_event, filtered_event in zip( # noqa: B905 df.to_dict("records"), filtered.to_dict("records") ): yield filtered_event diff --git a/tests/cmds/test_devices.py b/tests/cmds/test_devices.py index ab7fdd087..203b3b1f8 100644 --- a/tests/cmds/test_devices.py +++ b/tests/cmds/test_devices.py @@ -279,6 +279,42 @@ }, ] } +API_CLIENT_MATTER_RESPONSE = [ + { + "legalHoldUid": "123456789", + "name": "Test legal hold matter", + "description": "", + "notes": None, + "holdExtRef": None, + "active": True, + "creationDate": "2020-08-05T10:49:58.353-05:00", + "lastModified": "2020-08-05T10:49:58.358-05:00", + "creator": { + "userUid": "12345", + "username": "user@code42.com", + "email": "user@code42.com", + "userExtRef": None, + }, + "holdPolicyUid": "966191295667423997", + }, + { + "legalHoldUid": "987654321", + "name": "Another Matter", + "description": "", + "notes": None, + "holdExtRef": None, + "active": True, + "creationDate": "2020-05-20T15:58:31.375-05:00", + "lastModified": "2020-05-28T13:49:16.098-05:00", + "creator": { + "userUid": "76543", + "username": "user2@code42.com", + "email": "user2@code42.com", + "userExtRef": None, + }, + "holdPolicyUid": "946178665645035826", + }, +] ALL_CUSTODIANS_RESPONSE = { "legalHoldMemberships": [ { @@ -310,6 +346,35 @@ }, ] } +API_CLIENT_ALL_CUSTODIANS_RESPONSE = [ + { + "legalHoldMembershipUid": "99999", + "active": True, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": { + "legalHoldUid": "123456789", + "name": "Test legal hold matter", + }, + "user": { + "userUid": "840103986007089121", + "username": "ttranda_deactivated@ttrantest.com", + "email": "ttranda_deactivated@ttrantest.com", + "userExtRef": None, + }, + }, + { + "legalHoldMembershipUid": "88888", + "active": True, + "creationDate": "2020-07-16T08:50:23.405Z", + "legalHold": {"legalHoldUid": "987654321", "name": "Another Matter"}, + "user": { + "userUid": "840103986007089121", + "username": "ttranda_deactivated@ttrantest.com", + "email": "ttranda_deactivated@ttrantest.com", + "userExtRef": None, + }, + }, +] @pytest.fixture @@ -355,12 +420,18 @@ def users_list_generator(): yield TEST_USERS_LIST_PAGE -def matter_list_generator(): - yield MATTER_RESPONSE +def matter_list_generator(mocker, api_client=False): + if api_client: + yield create_mock_response(mocker, data=API_CLIENT_MATTER_RESPONSE) + else: + yield create_mock_response(mocker, data=MATTER_RESPONSE) -def custodian_list_generator(): - yield ALL_CUSTODIANS_RESPONSE +def custodian_list_generator(mocker, api_client=False): + if api_client: + yield create_mock_response(mocker, data=API_CLIENT_ALL_CUSTODIANS_RESPONSE) + else: + yield create_mock_response(mocker, data=ALL_CUSTODIANS_RESPONSE) @pytest.fixture @@ -446,14 +517,28 @@ def get_all_users_success(cli_state): @pytest.fixture -def get_all_matter_success(cli_state): - cli_state.sdk.legalhold.get_all_matters.return_value = matter_list_generator() +def get_all_matter_success(mocker, cli_state): + cli_state.sdk.legalhold.get_all_matters.return_value = matter_list_generator(mocker) + + +@pytest.fixture +def get_api_client_all_matter_success(mocker, cli_state): + cli_state.sdk.legalhold.get_all_matters.return_value = matter_list_generator( + mocker, api_client=True + ) @pytest.fixture -def get_all_custodian_success(cli_state): +def get_all_custodian_success(mocker, cli_state): cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( - custodian_list_generator() + custodian_list_generator(mocker) + ) + + +@pytest.fixture +def get_api_client_all_custodian_success(mocker, cli_state): + cli_state.sdk.legalhold.get_all_matter_custodians.return_value = ( + custodian_list_generator(mocker, api_client=True) ) @@ -740,6 +825,21 @@ def test_add_legal_hold_membership_to_device_dataframe_adds_legal_hold_columns_t assert "legalHoldName" in result.columns +def test_api_client_add_legal_hold_membership_to_device_dataframe_adds_legal_hold_columns_to_dataframe( + cli_state, get_api_client_all_matter_success, get_api_client_all_custodian_success +): + cli_state.sdk._auth_flag = 1 + testdf = DataFrame.from_records( + [ + {"userUid": "840103986007089121", "status": "Active"}, + {"userUid": "836473273124890369", "status": "Active, Deauthorized"}, + ] + ) + result = _add_legal_hold_membership_to_device_dataframe(cli_state.sdk, testdf) + assert "legalHoldUid" in result.columns + assert "legalHoldName" in result.columns + + def test_list_without_page_size_option_defaults_to_100_results_per_page( cli_state, runner ): diff --git a/tests/conftest.py b/tests/conftest.py index b609cacb1..d5bec08f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -297,7 +297,7 @@ def mock_dataframe_to_string(mocker): def create_mock_response(mocker, data=None, status=200): - if isinstance(data, dict): + if isinstance(data, (dict, list)): data = json.dumps(data) elif not data: data = "" From 260019f9ca4a6a3c9dc0c93bd7a87ac237ed6b9c Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 1 Feb 2023 11:34:45 -0600 Subject: [PATCH 335/349] Bugfix/security-data flattened checkpoint on v2 events (#399) * fix v2 checkpoint bug when format is json * fix v2 checkpoint bug when format is json * fix some new flake8 errors * style --- CHANGELOG.md | 6 ++++++ setup.cfg | 2 ++ src/code42cli/__version__.py | 2 +- src/code42cli/cmds/securitydata.py | 6 ++++-- tests/test_config.py | 4 ++-- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 944af8324..6785f321c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.16.4 - 2023-01-27 + +### Fixed + +- Bug in `security-data search|send-to` where using `--format json` and a checkpoint raised an error when configured for V2 file events. + ## 1.16.3 - 2022-12-08 ### Fixed diff --git a/setup.cfg b/setup.cfg index 58a0a0d03..22b1db081 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,5 +29,7 @@ ignore = W503 # exception chaining B904 + # manual quoting + B907 # up to 88 allowed by bugbear B950 max-line-length = 80 diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 6cc7415d1..67f76446f 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.16.3" +__version__ = "1.16.4" diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index ba5eb1086..03c637bc1 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -459,6 +459,8 @@ def search( "riskSeverity", ] + flatten = format in (OutputFormat.TABLE, OutputFormat.CSV) + if use_checkpoint: cursor = _get_file_event_cursor_store(state.profile.name) checkpoint = _handle_timestamp_checkpoint(cursor.get(use_checkpoint), state) @@ -466,7 +468,8 @@ def search( if state.profile.use_v2_file_events == "True": def checkpoint_func(event): - cursor.replace(use_checkpoint, event["event.id"]) + event_id = event["event.id"] if flatten else event["event"]["id"] + cursor.replace(use_checkpoint, event_id) else: @@ -477,7 +480,6 @@ def checkpoint_func(event): checkpoint = checkpoint_func = None query = _construct_query(state, begin, end, saved_search, advanced_query, or_query) - flatten = format in (OutputFormat.TABLE, OutputFormat.CSV) dfs = _get_all_file_events(state, query, checkpoint, flatten) formatter = FileEventsOutputFormatter(format, checkpoint_func=checkpoint_func) # sending to pager when checkpointing can be inaccurate due to pager buffering, so disallow pager diff --git a/tests/test_config.py b/tests/test_config.py index 2213e4e6a..c24371fc5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -151,7 +151,7 @@ def test_create_profile_when_given_default_name_does_not_create( self, config_parser_for_create ): accessor = ConfigAccessor(config_parser_for_create) - with pytest.raises(Exception): + with pytest.raises(NoConfigProfileError): accessor.create_profile( ConfigAccessor.DEFAULT_VALUE, "foo", "bar", False, False, False ) @@ -201,7 +201,7 @@ def test_update_profile_when_no_profile_exists_raises_exception( self, config_parser_for_multiple_profiles ): accessor = ConfigAccessor(config_parser_for_multiple_profiles) - with pytest.raises(Exception): + with pytest.raises(NoConfigProfileError): accessor.update_profile("Non-existent Profile") def test_update_profile_updates_profile(self, config_parser_for_multiple_profiles): From bafe9efb873019a08d21f98eff86f0c8bf72bbf0 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 1 Feb 2023 11:39:04 -0600 Subject: [PATCH 336/349] correct changelog date (#400) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6785f321c..87f8fb8b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## 1.16.4 - 2023-01-27 +## 1.16.4 - 2023-02-01 ### Fixed From 21d5585a0578c13dab4bbb206067a9e6dacc6a36 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 1 Feb 2023 11:42:29 -0600 Subject: [PATCH 337/349] Chore/changelog date (#401) * correct changelog date * correct changelog date * correct version num --- CHANGELOG.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87f8fb8b0..ee6f06fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## 1.16.4 - 2023-02-01 -### Fixed - -- Bug in `security-data search|send-to` where using `--format json` and a checkpoint raised an error when configured for V2 file events. - -## 1.16.3 - 2022-12-08 +## 1.16.3 - 2023-02-01 ### Fixed +- Bug in `security-data search|send-to` where using `--format json` and a checkpoint raised an error when configured for V2 file events. - Bug in `devices list` command when using `--include-legal-hold-membership` option with an API client auth profile. ## 1.16.2 - 2022-11-07 From 860b3283ad270376eed13f37042b885eef48fad8 Mon Sep 17 00:00:00 2001 From: Tim Abramson Date: Wed, 1 Feb 2023 14:52:02 -0600 Subject: [PATCH 338/349] Bugfix/send to checkpoint v2 events (#402) * fix `send-to` w/ checkpoint and v2 json events * bump version for release --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- src/code42cli/cmds/securitydata.py | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6f06fad..bcba330d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The intended audience of this file is for py42 consumers -- as such, changes tha how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## 1.16.3 - 2023-02-01 +## 1.16.5 - 2023-02-01 ### Fixed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 67f76446f..9e1406d5e 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.16.4" +__version__ = "1.16.5" diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 03c637bc1..0a0a2b777 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -513,6 +513,8 @@ def send_to( if state.profile.use_v2_file_events != "True": deprecation_warning(DEPRECATION_TEXT) + flatten = format in (OutputFormat.TABLE, OutputFormat.CSV) + if use_checkpoint: cursor = _get_file_event_cursor_store(state.profile.name) checkpoint = _handle_timestamp_checkpoint(cursor.get(use_checkpoint), state) @@ -520,7 +522,8 @@ def send_to( if state.profile.use_v2_file_events == "True": def checkpoint_func(event): - cursor.replace(use_checkpoint, event["event.id"]) + event_id = event["event.id"] if flatten else event["event"]["id"] + cursor.replace(use_checkpoint, event_id) else: From 50aae119755de0d0d6f9db3abe76699a69be551e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Apr 2023 10:12:36 -0500 Subject: [PATCH 339/349] Bump ipython from 7.16.3 to 8.10.0 (#403) * Bump ipython from 7.16.3 to 8.10.0 Bumps [ipython](https://github.com/ipython/ipython) from 7.16.3 to 8.10.0. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/7.16.3...8.10.0) --- updated-dependencies: - dependency-name: ipython dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Update setup.py * Update setup.py * Update __version__.py * Update CHANGELOG.md --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Tim Abramson --- CHANGELOG.md | 5 +++++ setup.py | 3 ++- src/code42cli/__version__.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcba330d9..0ac452e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.16.6 - 2023-04-12 + +### Fixed + +- Vulnerability in `ipython` dependency for installs on Python 3.8+ ## 1.16.5 - 2023-02-01 diff --git a/setup.py b/setup.py index cdf146a04..04663e40a 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,8 @@ "colorama>=0.4.3", "keyring==18.0.1", "keyrings.alt==3.2.0", - "ipython==7.16.3", + "ipython>=7.16.3;python_version<'3.8'", + "ipython>=8.10.0;python_version>='3.8'", "pandas>=1.1.3", "py42>=1.26.0", ], diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 9e1406d5e..032e9cb4e 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.16.5" +__version__ = "1.16.6" From a4177fe5d116b1f96e294eafed3b0fb89c94bf50 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Wed, 9 Aug 2023 08:14:04 -0600 Subject: [PATCH 340/349] chore/remove-ecm-apis (#405) * chore/remove-ecm-apis * fix docs --- .github/workflows/build.yml | 1 - CHANGELOG.md | 10 + README.md | 35 +- docs/commands.md | 4 - docs/commands/departingemployee.rst | 3 - docs/commands/highriskemployee.rst | 3 - docs/guides.md | 2 - docs/userguides/detectionlists.md | 62 --- src/code42cli/__version__.py | 2 +- src/code42cli/cmds/departing_employee.py | 183 -------- src/code42cli/cmds/detectionlists/__init__.py | 95 ---- src/code42cli/cmds/detectionlists/options.py | 11 - src/code42cli/cmds/high_risk_employee.py | 240 ---------- src/code42cli/cmds/shared.py | 2 +- src/code42cli/main.py | 4 - tests/cmds/conftest.py | 6 - tests/cmds/detectionlists/__init__.py | 0 tests/cmds/detectionlists/test_init.py | 52 --- tests/cmds/test_departing_employee.py | 441 ------------------ tests/cmds/test_high_risk_employee.py | 440 ----------------- tests/integration/test_departing_employee.py | 11 - tests/integration/test_high_risk_employee.py | 11 - 22 files changed, 13 insertions(+), 1605 deletions(-) delete mode 100644 docs/commands/departingemployee.rst delete mode 100644 docs/commands/highriskemployee.rst delete mode 100644 docs/userguides/detectionlists.md delete mode 100644 src/code42cli/cmds/departing_employee.py delete mode 100644 src/code42cli/cmds/detectionlists/__init__.py delete mode 100644 src/code42cli/cmds/detectionlists/options.py delete mode 100644 src/code42cli/cmds/high_risk_employee.py delete mode 100644 tests/cmds/detectionlists/__init__.py delete mode 100644 tests/cmds/detectionlists/test_init.py delete mode 100644 tests/cmds/test_departing_employee.py delete mode 100644 tests/cmds/test_high_risk_employee.py delete mode 100644 tests/integration/test_departing_employee.py delete mode 100644 tests/integration/test_high_risk_employee.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 472064a13..a5b3e18c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,7 +43,6 @@ jobs: 127.0.0.1 core 127.0.0.1 alerts 127.0.0.1 alert-rules - 127.0.0.1 detection-lists 127.0.0.1 audit-log 127.0.0.1 file-events 127.0.0.1 storage diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac452e40..861005dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.17.0 - 2023-08-04 + +### Removed + +- Removed the following command groups following deprecation: + - `detection-lists` + - `departing-employee` + - `high-risk-employee` +- APIs were replaced by the `watchlists` commands + ## 1.16.6 - 2023-04-12 ### Fixed diff --git a/README.md b/README.md index a3bb56d68..f265ba146 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ Use the `code42` command to interact with your Code42 environment. * `code42 security-data` is a CLI tool for extracting AED events. Additionally, you can choose to only get events that Code42 previously did not observe since you last recorded a checkpoint (provided you do not change your query). -* `code42 high-risk-employee` is a collection of tools for managing the high risk employee detection list. Similarly, - there is `code42 departing-employee`. +* `code42 watchlists` is a collection of tools for managing your employee watchlists. ## Requirements @@ -212,38 +211,6 @@ To get the results of a saved search, use the `--saved-search` option with your code42 security-data search --saved-search ``` -## Detection Lists - -You can both add and remove employees from detection lists using the CLI. This example uses `high-risk-employee`. - -```bash -code42 high-risk-employee add user@example.com --notes "These are notes" -code42 high-risk-employee remove user@example.com -``` - -Detection lists include a `bulk` command. To add employees to a list, you can pass in a csv file. First, generate the -csv file for the desired command by executing the `generate-template` command: - -```bash -code42 high-risk-employee bulk generate-template add -``` - -Notice that `generate-template` takes a `cmd` parameter for determining what type of template to generate. In the -example above, we give it the value `add` to generate a file for bulk adding users to the high risk employee list. - -Next, fill out the csv file with all the users and then pass it in as a parameter to `bulk add`: - -```bash -code42 high-risk-employee bulk add users_to_add.csv -``` - -Note that for `bulk remove`, the file only has to be an end-line delimited list of users with one line per user. - -## Known Issues - -In `security-data`, only the first 10,000 of each set of events containing the exact same insertion timestamp is -reported. - ## Troubleshooting If you keep getting prompted for your password, try resetting with `code42 profile reset-pw`. diff --git a/docs/commands.md b/docs/commands.md index 4ebf35098..79c54b172 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -17,8 +17,6 @@ Trusted Activities Users Watchlists - (DEPRECATED) Departing Employee - (DEPRECATED) High Risk Employee ``` * [Alert Rules](commands/alertrules.rst) @@ -32,5 +30,3 @@ * [Trusted Activities](commands/trustedactivities.rst) * [Users](commands/users.rst) * [Watchlists](commands/watchlists.rst) -* [(DEPRECATED) Departing Employee](commands/departingemployee.rst) -* [(DEPRECATED) High Risk Employee](commands/highriskemployee.rst) diff --git a/docs/commands/departingemployee.rst b/docs/commands/departingemployee.rst deleted file mode 100644 index a1c3ac5e5..000000000 --- a/docs/commands/departingemployee.rst +++ /dev/null @@ -1,3 +0,0 @@ -.. click:: code42cli.cmds.departing_employee:departing_employee - :prog: departing-employee - :nested: full diff --git a/docs/commands/highriskemployee.rst b/docs/commands/highriskemployee.rst deleted file mode 100644 index 4fd75700b..000000000 --- a/docs/commands/highriskemployee.rst +++ /dev/null @@ -1,3 +0,0 @@ -.. click:: code42cli.cmds.high_risk_employee:high_risk_employee - :prog: high-risk-employee - :nested: full diff --git a/docs/guides.md b/docs/guides.md index 86e9bc4f4..df4ddc01c 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -19,7 +19,6 @@ Add and manage cases Perform bulk actions Manage watchlist members - (DEPRECATED) Manage detection list users ``` * [Get started with the Code42 command-line interface (CLI)](userguides/gettingstarted.md) @@ -35,4 +34,3 @@ * [Add and manage cases](userguides/cases.md) * [Perform bulk actions](userguides/bulkcommands.md) * [Manage watchlist members](userguides/watchlists.md) -* [(DEPRECATED) Manage detection list users](userguides/detectionlists.md) diff --git a/docs/userguides/detectionlists.md b/docs/userguides/detectionlists.md deleted file mode 100644 index ce96c5470..000000000 --- a/docs/userguides/detectionlists.md +++ /dev/null @@ -1,62 +0,0 @@ -# (DEPRECATED) Manage Detection List Users - -```{eval-rst} -.. note:: - - Detection Lists have been replaced by Watchlists. - - Functionality for adding users to Departing Employee and High Risk Employee categories has been migrated to the :code:`code42 watchlists` command group. - - Functionality for listing and managing User Risk Profiles (e.g. adding Cloud Aliases, Notes, and Start/End dates to a user profile) has been migrated to the :code:`code42 users` command group. -``` - -Use the `departing-employee` commands to add employees to or remove employees from the Departing Employees list. Use the `high-risk-employee` commands to add employees to or remove employees from the High Risk list, or update risk tags for those users. - -To see a list of all the users currently in your organization: -- Export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Users_reference#Action_menu). -- Use the [CLI users commands](./users.md). - -## Get CSV template -To add multiple users to the Departing Employees list: - -1. Generate a CSV template. Below is an example command for generating a template to use to add employees to the Departing -Employees list. Once generated, the CSV file is saved to your current working directory. - -```bash -code42 departing-employee bulk generate-template add -``` - -2. Use the CSV template to enter the employees' information. Only the Code42 username is required. If added, -the departure date must be in yyyy-MM-dd format. Note: you are only able to add departure dates during the `add` -operation. If you don't include `--departure-date`, you can only add one later by removing and then re-adding the -employee. - -3. Save the CSV file. - -## Add users to the Departing Employees list - -Once you have entered the employees' information in the CSV file, use the `bulk add` command with the CSV file path to -add multiple users at once. For example: - -```bash -code42 departing-employee bulk add /Users/astrid.ludwig/add_departing_employee.csv -``` - -## Remove users -You can remove one or more users from the High Risk Employees list. Use `code42 departing-employee remove` to remove a -single user. - -To remove multiple users at once: - -1. Create a CSV file with one username per line. - -2. Save the file to your current working directory. - -3. Use the `bulk remove` command. For example: - -```bash -code42 high-risk-employee bulk remove /Users/matt.allen/remove_high_risk_employee.csv -``` - -Learn more about the [Departing Employee](../commands/departingemployee.md) and -[High Risk Employee](../commands/highriskemployee.md) commands. diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 032e9cb4e..30244104a 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.16.6" +__version__ = "1.17.0" diff --git a/src/code42cli/cmds/departing_employee.py b/src/code42cli/cmds/departing_employee.py deleted file mode 100644 index 5a683f45e..000000000 --- a/src/code42cli/cmds/departing_employee.py +++ /dev/null @@ -1,183 +0,0 @@ -import click -from py42.services.detectionlists.departing_employee import DepartingEmployeeFilters - -from code42cli.bulk import generate_template_cmd_factory -from code42cli.bulk import run_bulk_process -from code42cli.click_ext.groups import OrderedGroup -from code42cli.cmds.detectionlists import ALL_FILTER -from code42cli.cmds.detectionlists import get_choices -from code42cli.cmds.detectionlists import handle_filter_choice -from code42cli.cmds.detectionlists import list_employees -from code42cli.cmds.detectionlists import update_user -from code42cli.cmds.detectionlists.options import cloud_alias_option -from code42cli.cmds.detectionlists.options import notes_option -from code42cli.cmds.detectionlists.options import username_arg -from code42cli.cmds.shared import get_user_id -from code42cli.errors import Code42CLIError -from code42cli.file_readers import read_csv_arg -from code42cli.options import format_option -from code42cli.options import sdk_options -from code42cli.util import deprecation_warning - - -def _get_filter_choices(): - filters = DepartingEmployeeFilters.choices() - return get_choices(filters) - - -DEPRECATION_TEXT = "(DEPRECATED): Use `code42 watchlists` commands instead." - -DATE_FORMAT = "%Y-%m-%d" -filter_option = click.option( - "--filter", - help=f"Departing employee filter options. Defaults to {ALL_FILTER}.", - type=click.Choice(_get_filter_choices()), - default=ALL_FILTER, - callback=lambda ctx, param, arg: handle_filter_choice(arg), -) - - -@click.group( - cls=OrderedGroup, - help=f"{DEPRECATION_TEXT}\n\nAdd and remove employees from the Departing Employees detection list.", -) -@sdk_options(hidden=True) -def departing_employee(state): - pass - - -@departing_employee.command( - "list", - help=f"{DEPRECATION_TEXT}\n\nLists the users on the Departing Employees list.", -) -@sdk_options() -@format_option -@filter_option -def _list(state, format, filter): - deprecation_warning(DEPRECATION_TEXT) - employee_generator = _get_departing_employees(state.sdk, filter) - list_employees( - employee_generator, - format, - {"departureDate": "Departure Date"}, - ) - - -@departing_employee.command( - help=f"{DEPRECATION_TEXT}\n\nAdd a user to the Departing Employees detection list." -) -@username_arg -@click.option( - "--departure-date", - help="The date the employee is departing. Format: yyyy-MM-dd.", - type=click.DateTime(formats=[DATE_FORMAT]), -) -@cloud_alias_option -@notes_option -@sdk_options() -def add(state, username, cloud_alias, departure_date, notes): - - deprecation_warning(DEPRECATION_TEXT) - _add_departing_employee(state.sdk, username, cloud_alias, departure_date, notes) - - -@departing_employee.command( - help=f"{DEPRECATION_TEXT}\n\nRemove a user from the Departing Employees detection list." -) -@username_arg -@sdk_options() -def remove(state, username): - deprecation_warning(DEPRECATION_TEXT) - _remove_departing_employee(state.sdk, username) - - -@departing_employee.group( - cls=OrderedGroup, - help=f"{DEPRECATION_TEXT}\n\nTools for executing bulk departing employee actions.", -) -@sdk_options(hidden=True) -def bulk(state): - pass - - -DEPARTING_EMPLOYEE_CSV_HEADERS = ["username", "cloud_alias", "departure_date", "notes"] - -REMOVE_EMPLOYEE_HEADERS = ["username"] - -departing_employee_generate_template = generate_template_cmd_factory( - group_name="departing_employee", - commands_dict={ - "add": DEPARTING_EMPLOYEE_CSV_HEADERS, - "remove": REMOVE_EMPLOYEE_HEADERS, - }, -) -bulk.add_command(departing_employee_generate_template) - - -@bulk.command( - name="add", - help=f"{DEPRECATION_TEXT}\n\nBulk add users to the departing employees detection list using " - f"a CSV file with format: {','.join(DEPARTING_EMPLOYEE_CSV_HEADERS)}.", -) -@read_csv_arg(headers=DEPARTING_EMPLOYEE_CSV_HEADERS) -@sdk_options() -def bulk_add(state, csv_rows): - deprecation_warning(DEPRECATION_TEXT) - sdk = state.sdk # Force initialization of py42 to only happen once. - - def handle_row(username, cloud_alias, departure_date, notes): - if departure_date: - try: - departure_date = click.DateTime(formats=[DATE_FORMAT]).convert( - departure_date, None, None - ) - except click.exceptions.BadParameter: - message = ( - f"Invalid date {departure_date}, valid date format {DATE_FORMAT}." - ) - raise Code42CLIError(message) - _add_departing_employee(sdk, username, cloud_alias, departure_date, notes) - - run_bulk_process( - handle_row, - csv_rows, - progress_label="Adding users to the Departing Employees detection list:", - ) - - -@bulk.command( - name="remove", - help=f"{DEPRECATION_TEXT}\n\nBulk remove users from the departing employees detection list " - f"using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.", -) -@read_csv_arg(headers=REMOVE_EMPLOYEE_HEADERS) -@sdk_options() -def bulk_remove(state, csv_rows): - deprecation_warning(DEPRECATION_TEXT) - sdk = state.sdk - - def handle_row(username): - _remove_departing_employee(sdk, username) - - run_bulk_process( - handle_row, - csv_rows, - progress_label="Removing users from the Departing Employees detection list:", - ) - - -def _get_departing_employees(sdk, filter): - return sdk.detectionlists.departing_employee.get_all(filter) - - -def _add_departing_employee(sdk, username, cloud_alias, departure_date, notes): - if departure_date: - departure_date = departure_date.strftime(DATE_FORMAT) - user_id = get_user_id(sdk, username) - sdk.detectionlists.departing_employee.add(user_id, departure_date) - update_user(sdk, username, cloud_alias=cloud_alias, notes=notes) - - -def _remove_departing_employee(sdk, username): - user_id = get_user_id(sdk, username) - sdk.detectionlists.departing_employee.remove(user_id) diff --git a/src/code42cli/cmds/detectionlists/__init__.py b/src/code42cli/cmds/detectionlists/__init__.py deleted file mode 100644 index ddc039d40..000000000 --- a/src/code42cli/cmds/detectionlists/__init__.py +++ /dev/null @@ -1,95 +0,0 @@ -import click -from py42.services.detectionlists import _DetectionListFilters - -from code42cli.cmds.shared import get_user_id -from code42cli.output_formats import OutputFormat -from code42cli.output_formats import OutputFormatter - - -ALL_FILTER = "ALL" - - -def get_choices(filters): - filters.remove(_DetectionListFilters.OPEN) - filters.append(ALL_FILTER) - return filters - - -def handle_filter_choice(choice): - if choice == ALL_FILTER: - return _DetectionListFilters.OPEN - return choice - - -def list_employees(employee_generator, output_format, additional_header_items=None): - additional_header_items = additional_header_items or {} - header = {"userName": "Username", "notes": "Notes", **additional_header_items} - employee_list = [] - for employees in employee_generator: - for employee in employees["items"]: - if employee.get("notes") and output_format == OutputFormat.TABLE: - employee["notes"] = ( - employee["notes"].replace("\n", "\\n").replace("\t", "\\t") - ) - employee_list.append(employee) - if employee_list: - formatter = OutputFormatter(output_format, header) - formatter.echo_formatted_list(employee_list) - else: - click.echo("No users found.") - - -def update_user(sdk, username, cloud_alias=None, risk_tag=None, notes=None): - """Updates a detection list user. - - Args: - sdk (py42.sdk.SDKClient): py42 sdk. - username (str): The username of the user to update. - cloud_alias (str): A cloud alias to add to the user. - risk_tag (iter[str]): A list of risk tags associated with user. - notes (str): Notes about the user. - """ - user_id = get_user_id(sdk, username) - _update_cloud_alias(sdk, user_id, cloud_alias) - _update_risk_tags(sdk, username, risk_tag) - _update_notes(sdk, user_id, notes) - - -def _update_cloud_alias(sdk, user_id, cloud_alias): - if cloud_alias: - profile = sdk.detectionlists.get_user_by_id(user_id) - cloud_aliases = profile.data.get("cloudUsernames") or [] - for alias in cloud_aliases: - if alias != profile["userName"]: - sdk.detectionlists.remove_user_cloud_alias(user_id, alias) - sdk.detectionlists.add_user_cloud_alias(user_id, cloud_alias) - - -def _update_risk_tags(sdk, username, risk_tag): - if risk_tag: - add_risk_tags(sdk, username, risk_tag) - - -def _update_notes(sdk, user_id, notes): - if notes: - sdk.detectionlists.update_user_notes(user_id, notes) - - -def add_risk_tags(sdk, username, risk_tag): - risk_tag = handle_list_args(risk_tag) - user_id = get_user_id(sdk, username) - sdk.detectionlists.add_user_risk_tags(user_id, risk_tag) - - -def remove_risk_tags(sdk, username, risk_tag): - risk_tag = handle_list_args(risk_tag) - user_id = get_user_id(sdk, username) - sdk.detectionlists.remove_user_risk_tags(user_id, risk_tag) - - -def handle_list_args(list_arg): - """Converts str args to a list. Useful for `bulk` commands which don't use click's argument - parsing but instead pass in values from files, such as in the form "item1 item2".""" - if isinstance(list_arg, str): - return list_arg.split() - return list_arg diff --git a/src/code42cli/cmds/detectionlists/options.py b/src/code42cli/cmds/detectionlists/options.py deleted file mode 100644 index da538e934..000000000 --- a/src/code42cli/cmds/detectionlists/options.py +++ /dev/null @@ -1,11 +0,0 @@ -import click - -username_arg = click.argument("username") -cloud_alias_option = click.option( - "--cloud-alias", - help="If the employee has an email alias other than their Code42 username " - "that they use for cloud services such as Google Drive, OneDrive, or Box, " - "add and monitor the alias. WARNING: Adding a cloud alias will override any " - "existing cloud alias for this user.", -) -notes_option = click.option("--notes", help="Optional notes about the employee.") diff --git a/src/code42cli/cmds/high_risk_employee.py b/src/code42cli/cmds/high_risk_employee.py deleted file mode 100644 index a5153d1b2..000000000 --- a/src/code42cli/cmds/high_risk_employee.py +++ /dev/null @@ -1,240 +0,0 @@ -import click -from py42.clients.detectionlists import RiskTags -from py42.services.detectionlists.high_risk_employee import HighRiskEmployeeFilters - -from code42cli.bulk import generate_template_cmd_factory -from code42cli.bulk import run_bulk_process -from code42cli.click_ext.groups import OrderedGroup -from code42cli.cmds.detectionlists import add_risk_tags as _add_risk_tags -from code42cli.cmds.detectionlists import ALL_FILTER -from code42cli.cmds.detectionlists import get_choices -from code42cli.cmds.detectionlists import handle_filter_choice -from code42cli.cmds.detectionlists import handle_list_args -from code42cli.cmds.detectionlists import list_employees -from code42cli.cmds.detectionlists import remove_risk_tags as _remove_risk_tags -from code42cli.cmds.detectionlists import update_user -from code42cli.cmds.detectionlists.options import cloud_alias_option -from code42cli.cmds.detectionlists.options import notes_option -from code42cli.cmds.detectionlists.options import username_arg -from code42cli.cmds.shared import get_user_id -from code42cli.file_readers import read_csv_arg -from code42cli.options import format_option -from code42cli.options import sdk_options -from code42cli.util import deprecation_warning - -DEPRECATION_TEXT = "(DEPRECATED): Use `code42 watchlists` commands instead." - - -def _get_filter_choices(): - filters = HighRiskEmployeeFilters.choices() - return get_choices(filters) - - -filter_option = click.option( - "--filter", - help=f"High risk employee filter options. Defaults to {ALL_FILTER}.", - type=click.Choice(_get_filter_choices()), - default=ALL_FILTER, - callback=lambda ctx, param, arg: handle_filter_choice(arg), -) - - -risk_tag_option = click.option( - "-t", - "--risk-tag", - multiple=True, - type=click.Choice(RiskTags.choices()), - help="Risk tags associated with the employee.", -) - - -@click.group( - cls=OrderedGroup, - help=f"{DEPRECATION_TEXT}\n\nAdd and remove employees from the High Risk Employees detection list.", -) -@sdk_options(hidden=True) -def high_risk_employee(state): - pass - - -@high_risk_employee.command( - "list", - help=f"{DEPRECATION_TEXT}\n\nLists the employees on the High Risk Employee list.", -) -@sdk_options() -@format_option -@filter_option -def _list(state, format, filter): - deprecation_warning(DEPRECATION_TEXT) - employee_generator = _get_high_risk_employees(state.sdk, filter) - list_employees(employee_generator, format) - - -@high_risk_employee.command( - help=f"{DEPRECATION_TEXT}\n\nAdd a user to the high risk employees detection list." -) -@cloud_alias_option -@notes_option -@risk_tag_option -@username_arg -@sdk_options() -def add(state, username, cloud_alias, risk_tag, notes): - deprecation_warning(DEPRECATION_TEXT) - _add_high_risk_employee(state.sdk, username, cloud_alias, risk_tag, notes) - - -@high_risk_employee.command( - help=f"{DEPRECATION_TEXT}\n\nRemove a user from the high risk employees detection list." -) -@username_arg -@sdk_options() -def remove(state, username): - deprecation_warning(DEPRECATION_TEXT) - _remove_high_risk_employee(state.sdk, username) - - -@high_risk_employee.command( - help=f"{DEPRECATION_TEXT}\n\nAssociates risk tags with a user." -) -@username_arg -@risk_tag_option -@sdk_options() -def add_risk_tags(state, username, risk_tag): - deprecation_warning(DEPRECATION_TEXT) - _add_risk_tags(state.sdk, username, risk_tag) - - -@high_risk_employee.command( - help=f"{DEPRECATION_TEXT}\n\nDisassociates risk tags from a user." -) -@username_arg -@risk_tag_option -@sdk_options() -def remove_risk_tags(state, username, risk_tag): - deprecation_warning(DEPRECATION_TEXT) - _remove_risk_tags(state.sdk, username, risk_tag) - - -@high_risk_employee.group( - cls=OrderedGroup, - help=f"{DEPRECATION_TEXT}\n\nTools for executing high risk employee actions in bulk.", -) -@sdk_options(hidden=True) -def bulk(state): - pass - - -HIGH_RISK_EMPLOYEE_CSV_HEADERS = ["username", "cloud_alias", "risk_tag", "notes"] -RISK_TAG_CSV_HEADERS = ["username", "tag"] -REMOVE_EMPLOYEE_HEADERS = ["username"] - -high_risk_employee_generate_template = generate_template_cmd_factory( - group_name="high_risk_employee", - commands_dict={ - "add": HIGH_RISK_EMPLOYEE_CSV_HEADERS, - "remove": REMOVE_EMPLOYEE_HEADERS, - "add-risk-tags": RISK_TAG_CSV_HEADERS, - "remove-risk-tags": RISK_TAG_CSV_HEADERS, - }, -) -bulk.add_command(high_risk_employee_generate_template) - - -@bulk.command( - name="add", - help=f"{DEPRECATION_TEXT}\n\nBulk add users to the high risk employees detection list using a " - f"CSV file with format: {','.join(HIGH_RISK_EMPLOYEE_CSV_HEADERS)}.", -) -@read_csv_arg(headers=HIGH_RISK_EMPLOYEE_CSV_HEADERS) -@sdk_options() -def bulk_add(state, csv_rows): - deprecation_warning(DEPRECATION_TEXT) - sdk = state.sdk - - def handle_row(username, cloud_alias, risk_tag, notes): - _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes) - - run_bulk_process( - handle_row, - csv_rows, - progress_label="Adding users to high risk employee detection list:", - ) - - -@bulk.command( - name="remove", - help=f"{DEPRECATION_TEXT}\n\nBulk remove users from the high risk employees detection list " - f"using a CSV file with format {','.join(REMOVE_EMPLOYEE_HEADERS)}.", -) -@read_csv_arg(headers=REMOVE_EMPLOYEE_HEADERS) -@sdk_options() -def bulk_remove(state, csv_rows): - deprecation_warning(DEPRECATION_TEXT) - sdk = state.sdk - - def handle_row(username): - _remove_high_risk_employee(sdk, username) - - run_bulk_process( - handle_row, - csv_rows, - progress_label="Removing users from high risk employee detection list:", - ) - - -@bulk.command( - name="add-risk-tags", - help=f"{DEPRECATION_TEXT}\n\nAdds risk tags to users in bulk using a CSV file with format: " - f"{','.join(RISK_TAG_CSV_HEADERS)}.", -) -@read_csv_arg(headers=RISK_TAG_CSV_HEADERS) -@sdk_options() -def bulk_add_risk_tags(state, csv_rows): - deprecation_warning(DEPRECATION_TEXT) - sdk = state.sdk - - def handle_row(username, tag): - _add_risk_tags(sdk, username, tag) - - run_bulk_process( - handle_row, - csv_rows, - progress_label="Adding risk tags to users:", - ) - - -@bulk.command( - name="remove-risk-tags", - help=f"{DEPRECATION_TEXT}\n\nRemoves risk tags from users in bulk using a CSV file with " - f"format: {','.join(RISK_TAG_CSV_HEADERS)}.", -) -@read_csv_arg(headers=RISK_TAG_CSV_HEADERS) -@sdk_options() -def bulk_remove_risk_tags(state, csv_rows): - deprecation_warning(DEPRECATION_TEXT) - sdk = state.sdk - - def handle_row(username, tag): - _remove_risk_tags(sdk, username, tag) - - run_bulk_process( - handle_row, - csv_rows, - progress_label="Removing risk tags from users:", - ) - - -def _get_high_risk_employees(sdk, filter): - return sdk.detectionlists.high_risk_employee.get_all(filter) - - -def _add_high_risk_employee(sdk, username, cloud_alias, risk_tag, notes): - risk_tag = handle_list_args(risk_tag) - user_id = get_user_id(sdk, username) - sdk.detectionlists.high_risk_employee.add(user_id) - update_user(sdk, username, cloud_alias=cloud_alias, risk_tag=risk_tag, notes=notes) - - -def _remove_high_risk_employee(sdk, username): - user_id = get_user_id(sdk, username) - sdk.detectionlists.high_risk_employee.remove(user_id) diff --git a/src/code42cli/cmds/shared.py b/src/code42cli/cmds/shared.py index 4fa7a29bc..e87da7d88 100644 --- a/src/code42cli/cmds/shared.py +++ b/src/code42cli/cmds/shared.py @@ -5,7 +5,7 @@ @lru_cache(maxsize=None) def get_user_id(sdk, username): - """Returns the user's UID (referred to by `user_id` in detection lists). + """Returns the user's UID. Raises `UserDoesNotExistError` if the user doesn't exist in the Code42 server. Args: diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 5427c6530..9be3d29fc 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -16,9 +16,7 @@ from code42cli.cmds.alerts import alerts from code42cli.cmds.auditlogs import audit_logs from code42cli.cmds.cases import cases -from code42cli.cmds.departing_employee import departing_employee from code42cli.cmds.devices import devices -from code42cli.cmds.high_risk_employee import high_risk_employee from code42cli.cmds.legal_hold import legal_hold from code42cli.cmds.profile import profile from code42cli.cmds.securitydata import security_data @@ -88,9 +86,7 @@ def cli(state, python, script_dir): cli.add_command(alert_rules) cli.add_command(audit_logs) cli.add_command(cases) -cli.add_command(departing_employee) cli.add_command(devices) -cli.add_command(high_risk_employee) cli.add_command(legal_hold) cli.add_command(profile) cli.add_command(security_data) diff --git a/tests/cmds/conftest.py b/tests/cmds/conftest.py index 3dd9fea58..c60fdbf3f 100644 --- a/tests/cmds/conftest.py +++ b/tests/cmds/conftest.py @@ -3,7 +3,6 @@ import threading import pytest -from py42.exceptions import Py42UserAlreadyAddedError from py42.exceptions import Py42UserNotOnListError from py42.sdk import SDKClient from requests import HTTPError @@ -81,11 +80,6 @@ def custom_error(mocker): return err -@pytest.fixture -def user_already_added_error(custom_error): - return Py42UserAlreadyAddedError(custom_error, TEST_ID, "detection list") - - def get_filter_value_from_json(json, filter_index): return json_module.loads(str(json))["filters"][filter_index]["value"] diff --git a/tests/cmds/detectionlists/__init__.py b/tests/cmds/detectionlists/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/cmds/detectionlists/test_init.py b/tests/cmds/detectionlists/test_init.py deleted file mode 100644 index c07f3c404..000000000 --- a/tests/cmds/detectionlists/test_init.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest -from tests.conftest import create_mock_response - -from code42cli.cmds.detectionlists import update_user - -MOCK_USER_ID = "USER-ID" -MOCK_USER_NAME = "test@example.com" -MOCK_ALIAS = "alias@example" -MOCK_USER_PROFILE_RESPONSE = f""" -{{ - "type$": "USER_V2", - "tenantId": "TENANT-ID", - "userId": "{MOCK_USER_ID}", - "userName": "{MOCK_USER_NAME}", - "displayName": "Test", - "notes": "Notes", - "cloudUsernames": ["{MOCK_ALIAS}", "{MOCK_USER_NAME}"], - "riskFactors": ["HIGH_IMPACT_EMPLOYEE"] -}} -""" - - -@pytest.fixture -def user_response_with_cloud_aliases(mocker): - return create_mock_response(mocker, data=MOCK_USER_PROFILE_RESPONSE) - - -@pytest.fixture -def mock_user_id(mocker): - mock = mocker.patch("code42cli.cmds.detectionlists.get_user_id") - mock.return_value = MOCK_USER_ID - return mock - - -def test_update_user_when_given_cloud_alias_add_cloud_alias( - sdk, user_response_with_cloud_aliases, mock_user_id -): - sdk.detectionlists.get_user_by_id.return_value = user_response_with_cloud_aliases - update_user(sdk, MOCK_USER_NAME, cloud_alias="new.alias@exaple.com") - sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( - MOCK_USER_ID, "new.alias@exaple.com" - ) - - -def test_update_user_when_given_cloud_alias_first_removes_old_alias( - sdk, user_response_with_cloud_aliases, mock_user_id -): - sdk.detectionlists.get_user_by_id.return_value = user_response_with_cloud_aliases - update_user(sdk, MOCK_USER_NAME, cloud_alias="new.alias@exaple.com") - sdk.detectionlists.remove_user_cloud_alias.assert_called_once_with( - MOCK_USER_ID, MOCK_ALIAS - ) diff --git a/tests/cmds/test_departing_employee.py b/tests/cmds/test_departing_employee.py deleted file mode 100644 index d682709b4..000000000 --- a/tests/cmds/test_departing_employee.py +++ /dev/null @@ -1,441 +0,0 @@ -import json - -import pytest -from py42.services.detectionlists.departing_employee import DepartingEmployeeFilters -from tests.cmds.conftest import get_generator_for_get_all -from tests.cmds.conftest import get_user_not_on_list_side_effect -from tests.cmds.conftest import thread_safe_side_effect -from tests.conftest import TEST_ID - -from .conftest import TEST_EMPLOYEE -from code42cli.main import cli - - -DEPARTING_EMPLOYEE_ITEM = """{ - "type$": "DEPARTING_EMPLOYEE_V2", - "tenantId": "1111111-af5b-4231-9d8e-000000000", - "userId": "TEST USER UID", - "userName": "test.testerson@example.com", - "displayName": "Testerson", - "notes": "Leaving for competitor", - "createdAt": "2020-06-23T19:57:37.1345130Z", - "status": "OPEN", - "cloudUsernames": ["cloud@example.com"], - "departureDate": "2020-07-07" -} -""" -DEPARTING_EMPLOYEE_COMMAND = "departing-employee" - - -@pytest.fixture() -def mock_get_all_empty_state(mocker, cli_state_with_user): - generator = get_generator_for_get_all(mocker, None) - cli_state_with_user.sdk.detectionlists.departing_employee.get_all.side_effect = ( - generator - ) - return cli_state_with_user - - -@pytest.fixture() -def mock_get_all_state(mocker, cli_state_with_user): - generator = get_generator_for_get_all(mocker, DEPARTING_EMPLOYEE_ITEM) - cli_state_with_user.sdk.detectionlists.departing_employee.get_all.side_effect = ( - generator - ) - return cli_state_with_user - - -def test_list_departing_employees_lists_expected_properties(runner, mock_get_all_state): - res = runner.invoke(cli, ["departing-employee", "list"], obj=mock_get_all_state) - assert "Username" in res.output - assert "Notes" in res.output - assert "test.testerson@example.com" in res.output - assert "Leaving for competitor" in res.output - assert "Departure Date" in res.output - assert "2020-07-07" in res.output - - -def test_list_departing_employees_converts_all_to_open(runner, mock_get_all_state): - runner.invoke( - cli, ["departing-employee", "list", "--filter", "ALL"], obj=mock_get_all_state - ) - mock_get_all_state.sdk.detectionlists.departing_employee.get_all.assert_called_once_with( - DepartingEmployeeFilters.OPEN - ) - - -def test_list_departing_employees_when_given_raw_json_lists_expected_properties( - runner, mock_get_all_state -): - res = runner.invoke( - cli, ["departing-employee", "list", "-f", "RAW-JSON"], obj=mock_get_all_state - ) - assert "userName" in res.output - assert "notes" in res.output - assert "test.testerson@example.com" in res.output - assert "Leaving for competitor" in res.output - assert "cloudUsernames" in res.output - assert "cloud@example.com" in res.output - assert "departureDate" in res.output - assert "2020-07-07" in res.output - - -def test_list_departing_employees_when_no_employees_echos_expected_message( - runner, mock_get_all_empty_state -): - res = runner.invoke( - cli, ["departing-employee", "list"], obj=mock_get_all_empty_state - ) - assert "No users found." in res.output - - -def test_list_departing_employees_when_table_format_and_notes_contains_newlines_escapes_them( - runner, mocker, cli_state_with_user -): - new_line_text = str(DEPARTING_EMPLOYEE_ITEM).replace( - "Leaving for competitor", r"Line1\nLine2" - ) - generator = get_generator_for_get_all(mocker, new_line_text) - cli_state_with_user.sdk.detectionlists.departing_employee.get_all.side_effect = ( - generator - ) - res = runner.invoke(cli, ["departing-employee", "list"], obj=cli_state_with_user) - assert "Line1\\nLine2" in res.output - - -def test_list_departing_employees_uses_filter_option(runner, mock_get_all_state): - runner.invoke( - cli, - [ - "departing-employee", - "list", - "--filter", - DepartingEmployeeFilters.EXFILTRATION_30_DAYS, - ], - obj=mock_get_all_state, - ) - mock_get_all_state.sdk.detectionlists.departing_employee.get_all.assert_called_once_with( - DepartingEmployeeFilters.EXFILTRATION_30_DAYS - ) - - -def test_list_departing_employees_handles_employees_with_no_notes( - runner, mocker, cli_state_with_user -): - hr_json = json.loads(DEPARTING_EMPLOYEE_ITEM) - hr_json["notes"] = None - new_text = json.dumps(hr_json) - generator = get_generator_for_get_all(mocker, new_text) - cli_state_with_user.sdk.detectionlists.departing_employee.get_all.side_effect = ( - generator - ) - res = runner.invoke(cli, ["departing-employee", "list"], obj=cli_state_with_user) - assert "None" in res.output - - -def test_add_departing_employee_when_given_cloud_alias_adds_alias( - runner, cli_state_with_user -): - alias = "departing employee alias" - runner.invoke( - cli, - ["departing-employee", "add", TEST_EMPLOYEE, "--cloud-alias", alias], - obj=cli_state_with_user, - ) - cli_state_with_user.sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( - TEST_ID, alias - ) - - -def test_add_departing_employee_when_given_notes_updates_notes( - runner, cli_state_with_user, profile -): - notes = "is leaving" - runner.invoke( - cli, - ["departing-employee", "add", TEST_EMPLOYEE, "--notes", notes], - obj=cli_state_with_user, - ) - cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with( - TEST_ID, notes - ) - - -def test_add_departing_employee_adds( - runner, - cli_state_with_user, -): - departure_date = "2020-02-02" - runner.invoke( - cli, - [ - "departing-employee", - "add", - TEST_EMPLOYEE, - "--departure-date", - departure_date, - ], - obj=cli_state_with_user, - ) - cli_state_with_user.sdk.detectionlists.departing_employee.add.assert_called_once_with( - TEST_ID, "2020-02-02" - ) - - -def test_add_departing_employee_when_user_does_not_exist_exits( - runner, cli_state_without_user -): - result = runner.invoke( - cli, ["departing-employee", "add", TEST_EMPLOYEE], obj=cli_state_without_user - ) - assert result.exit_code == 1 - assert ( - f"User '{TEST_EMPLOYEE}' does not exist or you do not have permission to view them." - in result.output - ) - - -def test_add_departing_employee_when_user_already_exits_with_correct_message( - runner, cli_state_with_user, user_already_added_error -): - def add_user(user): - raise user_already_added_error - - cli_state_with_user.sdk.detectionlists.departing_employee.add.side_effect = add_user - result = runner.invoke( - cli, ["departing-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user - ) - assert result.exit_code == 1 - assert f"'{TEST_EMPLOYEE}' is already on the departing-employee list." - - -def test_remove_departing_employee_calls_remove(runner, cli_state_with_user): - runner.invoke( - cli, ["departing-employee", "remove", TEST_EMPLOYEE], obj=cli_state_with_user - ) - cli_state_with_user.sdk.detectionlists.departing_employee.remove.assert_called_once_with( - TEST_ID - ) - - -def test_remove_departing_employee_when_user_does_not_exist_exits( - runner, cli_state_without_user -): - result = runner.invoke( - cli, ["departing-employee", "remove", TEST_EMPLOYEE], obj=cli_state_without_user - ) - assert result.exit_code == 1 - assert ( - f"User '{TEST_EMPLOYEE}' does not exist or you do not have permission to view them." - in result.output - ) - - -def test_add_bulk_users_calls_expected_py42_methods(runner, cli_state): - de_add_user = thread_safe_side_effect() - add_user_cloud_alias = thread_safe_side_effect() - update_user_notes = thread_safe_side_effect() - - cli_state.sdk.detectionlists.departing_employee.add.side_effect = de_add_user - cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = add_user_cloud_alias - cli_state.sdk.detectionlists.update_user_notes.side_effect = update_user_notes - - with runner.isolated_filesystem(): - with open("test_add.csv", "w") as csv: - csv.writelines( - [ - "username,cloud_alias,departure_date,notes\n", - "test_user,test_alias,2020-01-01,test_note\n", - "test_user_2,test_alias_2,2020-02-01,test_note_2\n", - "test_user_3,,,\n", - "test_user_3,,2020-30-02,\n", - "test_user_3,,20-02-2020,\n", - ] - ) - runner.invoke( - cli, ["departing-employee", "bulk", "add", "test_add.csv"], obj=cli_state - ) - de_add_user_call_args = [call[1] for call in de_add_user.call_args_list] - assert de_add_user.call_count == 3 - assert "2020-01-01" in de_add_user_call_args - assert "2020-02-01" in de_add_user_call_args - assert None in de_add_user_call_args - - add_user_cloud_alias_call_args = [ - call[1] for call in add_user_cloud_alias.call_args_list - ] - assert add_user_cloud_alias.call_count == 2 - assert "test_alias" in add_user_cloud_alias_call_args - assert "test_alias_2" in add_user_cloud_alias_call_args - - update_user_notes_call_args = [call[1] for call in update_user_notes.call_args_list] - assert update_user_notes.call_count == 2 - assert "test_note" in update_user_notes_call_args - assert "test_note_2" in update_user_notes_call_args - - -def test_remove_bulk_users_uses_expected_arguments(runner, mocker, cli_state_with_user): - bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove.csv", "w") as csv: - csv.writelines(["username\n", "test_user1\n", "test_user2\n"]) - runner.invoke( - cli, - ["departing-employee", "bulk", "remove", "test_remove.csv"], - obj=cli_state_with_user, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test_user1"}, - {"username": "test_user2"}, - ] - - -def test_remove_bulk_users_uses_expected_arguments_when_no_header( - runner, mocker, cli_state_with_user -): - bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove.csv", "w") as csv: - csv.writelines(["test_user1\n", "test_user2\n"]) - runner.invoke( - cli, - ["departing-employee", "bulk", "remove", "test_remove.csv"], - obj=cli_state_with_user, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test_user1"}, - {"username": "test_user2"}, - ] - - -def test_remove_bulk_users_uses_expected_arguments_when_extra_columns( - runner, mocker, cli_state_with_user -): - bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove.csv", "w") as csv: - csv.writelines( - [ - "username,test_column\n", - "test_user1,test_value1\n", - "test_user2,test_value2\n", - ] - ) - runner.invoke( - cli, - ["departing-employee", "bulk", "remove", "test_remove.csv"], - obj=cli_state_with_user, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test_user1"}, - {"username": "test_user2"}, - ] - - -def test_remove_bulk_users_uses_expected_arguments_when_flat_file( - runner, mocker, cli_state_with_user -): - bulk_processor = mocker.patch("code42cli.cmds.departing_employee.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove.txt", "w") as csv: - csv.writelines(["# username\n", "test_user1\n", "test_user2\n"]) - runner.invoke( - cli, - ["departing-employee", "bulk", "remove", "test_remove.txt"], - obj=cli_state_with_user, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test_user1"}, - {"username": "test_user2"}, - ] - - -def test_add_departing_employee_when_invalid_date_validation_raises_error( - runner, cli_state_with_user -): - # day is out of range for month - departure_date = "2020-02-30" - result = runner.invoke( - cli, - [ - "departing-employee", - "add", - TEST_EMPLOYEE, - "--departure-date", - departure_date, - ], - obj=cli_state_with_user, - ) - assert result.exit_code == 2 - assert ( - "Invalid value for '--departure-date': '2020-02-30' does not match the format '%Y-%m-%d'" - in result.output # invalid datetime format - ) or ( - "Invalid value for '--departure-date': invalid datetime format" in result.output - ) - - -def test_add_departing_employee_when_invalid_date_format_validation_raises_error( - runner, cli_state_with_user -): - departure_date = "2020-30-01" - result = runner.invoke( - cli, - [ - "departing-employee", - "add", - TEST_EMPLOYEE, - "--departure-date", - departure_date, - ], - obj=cli_state_with_user, - ) - assert result.exit_code == 2 - assert ( - "Invalid value for '--departure-date': '2020-30-01' does not match the format '%Y-%m-%d'" - in result.output - ) or ( - "Invalid value for '--departure-date': invalid datetime format" in result.output - ) - - -def test_remove_departing_employee_when_user_not_on_list_prints_expected_error( - mocker, runner, cli_state -): - cli_state.sdk.detectionlists.departing_employee.remove.side_effect = ( - get_user_not_on_list_side_effect(mocker, "departing-employee") - ) - test_username = "test@example.com" - result = runner.invoke( - cli, ["departing-employee", "remove", test_username], obj=cli_state - ) - assert ( - f"User with ID '{TEST_ID}' is not currently on the departing-employee list." - in result.output - ) - - -@pytest.mark.parametrize( - "command, error_msg", - [ - (f"{DEPARTING_EMPLOYEE_COMMAND} add", "Missing argument 'USERNAME'."), - ( - f"{DEPARTING_EMPLOYEE_COMMAND} remove", - "Missing argument 'USERNAME'.", - ), - ( - f"{DEPARTING_EMPLOYEE_COMMAND} bulk add", - "Missing argument 'CSV_FILE'.", - ), - ( - f"{DEPARTING_EMPLOYEE_COMMAND} bulk remove", - "Missing argument 'CSV_FILE'.", - ), - ], -) -def test_departing_employee_command_when_missing_required_parameters_returns_error( - command, error_msg, cli_state, runner -): - result = runner.invoke(cli, command.split(" "), obj=cli_state) - assert result.exit_code == 2 - assert error_msg in "".join(result.output) diff --git a/tests/cmds/test_high_risk_employee.py b/tests/cmds/test_high_risk_employee.py deleted file mode 100644 index 136e92395..000000000 --- a/tests/cmds/test_high_risk_employee.py +++ /dev/null @@ -1,440 +0,0 @@ -import json - -import pytest -from py42.services.detectionlists.high_risk_employee import HighRiskEmployeeFilters -from tests.cmds.conftest import get_generator_for_get_all -from tests.cmds.conftest import get_user_not_on_list_side_effect -from tests.cmds.conftest import TEST_EMPLOYEE -from tests.cmds.conftest import thread_safe_side_effect -from tests.conftest import TEST_ID - -from code42cli.main import cli - -_NAMESPACE = "code42cli.cmds.high_risk_employee" - - -HIGH_RISK_EMPLOYEE_ITEM = """{ - "type$": "HIGH_RISK_EMPLOYEE_V2", - "tenantId": "1111111-af5b-4231-9d8e-000000000", - "userId": "TEST USER UID", - "userName": "test.testerson@example.com", - "displayName": "Testerson", - "notes": "Leaving for competitor", - "createdAt": "2020-06-23T19:57:37.1345130Z", - "status": "OPEN", - "cloudUsernames": ["cloud@example.com"], - "riskFactors": ["PERFORMANCE_CONCERNS"] -} -""" -HR_EMPLOYEE_COMMAND = "high-risk-employee" - - -@pytest.fixture() -def mock_get_all_empty_state(mocker, cli_state_with_user): - generator = get_generator_for_get_all(mocker, None) - cli_state_with_user.sdk.detectionlists.high_risk_employee.get_all.side_effect = ( - generator - ) - return cli_state_with_user - - -@pytest.fixture() -def mock_get_all_state(mocker, cli_state_with_user): - generator = get_generator_for_get_all(mocker, HIGH_RISK_EMPLOYEE_ITEM) - cli_state_with_user.sdk.detectionlists.high_risk_employee.get_all.side_effect = ( - generator - ) - return cli_state_with_user - - -def test_list_high_risk_employees_lists_expected_properties(runner, mock_get_all_state): - res = runner.invoke(cli, ["high-risk-employee", "list"], obj=mock_get_all_state) - assert "Username" in res.output - assert "Notes" in res.output - assert "test.testerson@example.com" in res.output - - -def test_list_high_risk_employees_converts_all_to_open(runner, mock_get_all_state): - runner.invoke( - cli, ["high-risk-employee", "list", "--filter", "ALL"], obj=mock_get_all_state - ) - mock_get_all_state.sdk.detectionlists.high_risk_employee.get_all.assert_called_once_with( - HighRiskEmployeeFilters.OPEN - ) - - -def test_list_high_risk_employees_when_given_raw_json_lists_expected_properties( - runner, mock_get_all_state -): - res = runner.invoke( - cli, ["high-risk-employee", "list", "-f", "RAW-JSON"], obj=mock_get_all_state - ) - assert "userName" in res.output - assert "notes" in res.output - assert "test.testerson@example.com" in res.output - assert "Leaving for competitor" in res.output - assert "cloudUsernames" in res.output - assert "cloud@example.com" in res.output - assert "riskFactors" in res.output - assert "PERFORMANCE_CONCERNS" in res.output - - -def test_list_high_risk_employees_when_no_employees_echos_expected_message( - runner, mock_get_all_empty_state -): - res = runner.invoke( - cli, ["high-risk-employee", "list"], obj=mock_get_all_empty_state - ) - assert "No users found." in res.output - - -def test_list_high_risk_employees_uses_filter_option(runner, mock_get_all_state): - runner.invoke( - cli, - [ - "high-risk-employee", - "list", - "--filter", - HighRiskEmployeeFilters.EXFILTRATION_30_DAYS, - ], - obj=mock_get_all_state, - ) - mock_get_all_state.sdk.detectionlists.high_risk_employee.get_all.assert_called_once_with( - HighRiskEmployeeFilters.EXFILTRATION_30_DAYS, - ) - - -def test_list_high_risk_employees_when_table_format_and_notes_contains_newlines_escapes_them( - runner, mocker, cli_state_with_user -): - new_line_text = str(HIGH_RISK_EMPLOYEE_ITEM).replace( - "Leaving for competitor", r"Line1\nLine2" - ) - generator = get_generator_for_get_all(mocker, new_line_text) - cli_state_with_user.sdk.detectionlists.high_risk_employee.get_all.side_effect = ( - generator - ) - res = runner.invoke(cli, ["high-risk-employee", "list"], obj=cli_state_with_user) - assert "Line1\\nLine2" in res.output - - -def test_list_high_risk_employees_handles_employees_with_no_notes( - runner, mocker, cli_state_with_user -): - hr_json = json.loads(HIGH_RISK_EMPLOYEE_ITEM) - hr_json["notes"] = None - new_text = json.dumps(hr_json) - generator = get_generator_for_get_all(mocker, new_text) - cli_state_with_user.sdk.detectionlists.high_risk_employee.get_all.side_effect = ( - generator - ) - res = runner.invoke(cli, ["high-risk-employee", "list"], obj=cli_state_with_user) - assert "None" in res.output - - -def test_add_high_risk_employee_adds(runner, cli_state_with_user): - runner.invoke( - cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user - ) - cli_state_with_user.sdk.detectionlists.high_risk_employee.add.assert_called_once_with( - TEST_ID - ) - - -def test_add_high_risk_employee_when_given_cloud_alias_adds_alias( - runner, cli_state_with_user -): - alias = "risk employee alias" - runner.invoke( - cli, - ["high-risk-employee", "add", TEST_EMPLOYEE, "--cloud-alias", alias], - obj=cli_state_with_user, - ) - cli_state_with_user.sdk.detectionlists.add_user_cloud_alias.assert_called_once_with( - TEST_ID, alias - ) - - -def test_add_high_risk_employee_when_given_risk_tags_adds_tags( - runner, cli_state_with_user -): - runner.invoke( - cli, - [ - "high-risk-employee", - "add", - TEST_EMPLOYEE, - "-t", - "FLIGHT_RISK", - "-t", - "ELEVATED_ACCESS_PRIVILEGES", - "-t", - "POOR_SECURITY_PRACTICES", - ], - obj=cli_state_with_user, - ) - cli_state_with_user.sdk.detectionlists.add_user_risk_tags.assert_called_once_with( - TEST_ID, - ("FLIGHT_RISK", "ELEVATED_ACCESS_PRIVILEGES", "POOR_SECURITY_PRACTICES"), - ) - - -def test_add_high_risk_employee_when_given_notes_updates_notes( - runner, cli_state_with_user -): - notes = "being risky" - runner.invoke( - cli, - ["high-risk-employee", "add", TEST_EMPLOYEE, "--notes", notes], - obj=cli_state_with_user, - ) - cli_state_with_user.sdk.detectionlists.update_user_notes.assert_called_once_with( - TEST_ID, notes - ) - - -def test_add_high_risk_employee_when_user_does_not_exist_exits_with_correct_message( - runner, cli_state_without_user -): - result = runner.invoke( - cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_without_user - ) - assert result.exit_code == 1 - assert ( - f"User '{TEST_EMPLOYEE}' does not exist or you do not have permission to view them." - in result.output - ) - - -def test_add_high_risk_employee_when_user_already_added_exits_with_correct_message( - runner, cli_state_with_user, user_already_added_error -): - def add_user(user): - raise user_already_added_error - - cli_state_with_user.sdk.detectionlists.high_risk_employee.add.side_effect = add_user - - result = runner.invoke( - cli, ["high-risk-employee", "add", TEST_EMPLOYEE], obj=cli_state_with_user - ) - assert result.exit_code == 1 - assert "User with ID TEST_ID is already on the detection list" in result.output - - -def test_remove_high_risk_employee_calls_remove(runner, cli_state_with_user): - runner.invoke( - cli, ["high-risk-employee", "remove", TEST_EMPLOYEE], obj=cli_state_with_user - ) - cli_state_with_user.sdk.detectionlists.high_risk_employee.remove.assert_called_once_with( - TEST_ID - ) - - -def test_remove_high_risk_employee_when_user_does_not_exist_exits_with_correct_message( - runner, cli_state_without_user -): - result = runner.invoke( - cli, ["high-risk-employee", "remove", TEST_EMPLOYEE], obj=cli_state_without_user - ) - assert result.exit_code == 1 - assert ( - f"User '{TEST_EMPLOYEE}' does not exist or you do not have permission to view them." - in result.output - ) - - -def test_bulk_add_employees_calls_expected_py42_methods(runner, cli_state): - add_user_cloud_alias = thread_safe_side_effect() - add_user_risk_tags = thread_safe_side_effect() - update_user_notes = thread_safe_side_effect() - hre_add_user = thread_safe_side_effect() - - cli_state.sdk.detectionlists.add_user_cloud_alias.side_effect = add_user_cloud_alias - cli_state.sdk.detectionlists.add_user_risk_tags.side_effect = add_user_risk_tags - cli_state.sdk.detectionlists.update_user_notes.side_effect = update_user_notes - cli_state.sdk.detectionlists.high_risk_employee.add.side_effect = hre_add_user - - with runner.isolated_filesystem(): - with open("test_add.csv", "w") as csv: - csv.writelines( - [ - "username,cloud_alias,risk_tag,notes\n", - "test_user,test_alias,test_tag_1 test_tag_2,test_note\n", - "test_user_2,test_alias_2,test_tag_3,test_note_2\n", - "test_user_3,,,\n", - ] - ) - runner.invoke( - cli, ["high-risk-employee", "bulk", "add", "test_add.csv"], obj=cli_state - ) - alias_args = [call[1] for call in add_user_cloud_alias.call_args_list] - assert add_user_cloud_alias.call_count == 2 - assert "test_alias" in alias_args - assert "test_alias_2" in alias_args - - add_risk_tags_call_args = [call[1] for call in add_user_risk_tags.call_args_list] - assert add_user_risk_tags.call_count == 2 - assert ["test_tag_1", "test_tag_2"] in add_risk_tags_call_args - assert ["test_tag_3"] in add_risk_tags_call_args - - add_notes_call_args = [call[1] for call in update_user_notes.call_args_list] - assert update_user_notes.call_count == 2 - assert "test_note" in add_notes_call_args - assert "test_note_2" in add_notes_call_args - - assert hre_add_user.call_count == 3 - - -def test_bulk_remove_employees_uses_expected_arguments(runner, cli_state, mocker): - bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove.csv", "w") as csv: - csv.writelines(["username\n", "test@example.com\n", "test2@example.com"]) - runner.invoke( - cli, - ["high-risk-employee", "bulk", "remove", "test_remove.csv"], - obj=cli_state, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test@example.com"}, - {"username": "test2@example.com"}, - ] - - -def test_bulk_remove_employees_uses_expected_arguments_when_extra_columns( - runner, cli_state, mocker -): - bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove.csv", "w") as csv: - csv.writelines( - [ - "username,test_column\n", - "test@example.com,test_value1\n", - "test2@example.com,test_value2\n", - ] - ) - runner.invoke( - cli, - ["high-risk-employee", "bulk", "remove", "test_remove.csv"], - obj=cli_state, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test@example.com"}, - {"username": "test2@example.com"}, - ] - - -def test_bulk_remove_employees_uses_expected_arguments_when_flat_file( - runner, cli_state, mocker -): - bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove.txt", "w") as csv: - csv.writelines(["# username\n", "test@example.com\n", "test2@example.com"]) - runner.invoke( - cli, - ["high-risk-employee", "bulk", "remove", "test_remove.txt"], - obj=cli_state, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test@example.com"}, - {"username": "test2@example.com"}, - ] - - -def test_bulk_remove_employees_uses_expected_arguments_when_no_header( - runner, cli_state, mocker -): - bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove.csv", "w") as csv: - csv.writelines(["test@example.com\n", "test2@example.com"]) - runner.invoke( - cli, - ["high-risk-employee", "bulk", "remove", "test_remove.csv"], - obj=cli_state, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test@example.com"}, - {"username": "test2@example.com"}, - ] - - -def test_bulk_add_risk_tags_uses_expected_arguments(runner, cli_state, mocker): - bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_add_risk_tags.csv", "w") as csv: - csv.writelines( - ["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"] - ) - runner.invoke( - cli, - ["high-risk-employee", "bulk", "add-risk-tags", "test_add_risk_tags.csv"], - obj=cli_state, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test@example.com", "tag": "tag1"}, - {"username": "test2@example.com", "tag": "tag2"}, - ] - - -def test_bulk_remove_risk_tags_uses_expected_arguments(runner, cli_state, mocker): - bulk_processor = mocker.patch(f"{_NAMESPACE}.run_bulk_process") - with runner.isolated_filesystem(): - with open("test_remove_risk_tags.csv", "w") as csv: - csv.writelines( - ["username,tag\n", "test@example.com,tag1\n", "test2@example.com,tag2"] - ) - runner.invoke( - cli, - [ - "high-risk-employee", - "bulk", - "remove-risk-tags", - "test_remove_risk_tags.csv", - ], - obj=cli_state, - ) - assert bulk_processor.call_args[0][1] == [ - {"username": "test@example.com", "tag": "tag1"}, - {"username": "test2@example.com", "tag": "tag2"}, - ] - - -def test_remove_high_risk_employee_when_user_not_on_list_prints_expected_error( - mocker, runner, cli_state -): - cli_state.sdk.detectionlists.high_risk_employee.remove.side_effect = ( - get_user_not_on_list_side_effect(mocker, "high-risk-employee") - ) - test_username = "test@example.com" - result = runner.invoke( - cli, ["high-risk-employee", "remove", test_username], obj=cli_state - ) - assert ( - f"User with ID '{TEST_ID}' is not currently on the high-risk-employee list." - in result.output - ) - - -@pytest.mark.parametrize( - "command, error_msg", - [ - (f"{HR_EMPLOYEE_COMMAND} add", "Missing argument 'USERNAME'."), - (f"{HR_EMPLOYEE_COMMAND} remove", "Missing argument 'USERNAME'."), - (f"{HR_EMPLOYEE_COMMAND} bulk add", "Missing argument 'CSV_FILE'."), - (f"{HR_EMPLOYEE_COMMAND} bulk remove", "Missing argument 'CSV_FILE'."), - (f"{HR_EMPLOYEE_COMMAND} bulk add-risk-tags", "Missing argument 'CSV_FILE'."), - ( - f"{HR_EMPLOYEE_COMMAND} bulk remove-risk-tags", - "Missing argument 'CSV_FILE'.", - ), - ], -) -def test_hr_employee_command_when_missing_required_parameters_returns_error( - command, error_msg, runner, cli_state -): - result = runner.invoke(cli, command.split(" "), obj=cli_state) - assert result.exit_code == 2 - assert error_msg in "".join(result.output) diff --git a/tests/integration/test_departing_employee.py b/tests/integration/test_departing_employee.py deleted file mode 100644 index f3dca5be5..000000000 --- a/tests/integration/test_departing_employee.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest -from tests.integration.conftest import append_profile -from tests.integration.util import assert_test_is_successful - - -@pytest.mark.integration -def test_departing_employee_list_command_returns_success_return_code( - runner, integration_test_profile -): - command = "departing-employee list" - assert_test_is_successful(runner, append_profile(command)) diff --git a/tests/integration/test_high_risk_employee.py b/tests/integration/test_high_risk_employee.py deleted file mode 100644 index 26aeb550a..000000000 --- a/tests/integration/test_high_risk_employee.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest -from tests.integration.conftest import append_profile -from tests.integration.util import assert_test_is_successful - - -@pytest.mark.integration -def test_high_risk_employee_list_command_returns_success_return_code( - runner, integration_test_profile -): - command = "high-risk-employee list" - assert_test_is_successful(runner, append_profile(command)) From a622c7de7eed904e28bde28dd0c7f75907254e16 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:36:29 -0600 Subject: [PATCH 341/349] Update build for python 3.12 (#407) --- .github/workflows/build.yml | 12 ++++++------ .github/workflows/docs.yml | 4 +++- .github/workflows/nightly.yml | 2 +- .github/workflows/style.yml | 4 +++- .pre-commit-config.yaml | 2 +- setup.cfg | 2 ++ setup.py | 4 +++- tests/logger/test_init.py | 10 ++++------ tox.ini | 3 ++- 9 files changed, 25 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5b3e18c4..d9ec99b83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,20 +14,20 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 - with: - path: code42cli - name: Setup Python uses: actions/setup-python@v1 with: python-version: ${{ matrix.python }} - name: Install tox - run: pip install tox==3.17.1 + run: | + pip install tox==3.17.1 + pip install . - name: Run Unit tests - run: cd code42cli; tox -e py # Run tox using the version of Python in `PATH` + run: tox -e py # Run tox using the version of Python in `PATH` - name: Submit coverage report uses: codecov/codecov-action@v1.0.7 with: @@ -56,4 +56,4 @@ jobs: - name: Start up the mock servers run: cd code42-mock-servers; docker-compose up -d --build - name: Run the integration tests - run: sleep 15; cd code42cli; tox -e integration + run: sleep 15; tox -e integration diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4afff063f..26262813d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,6 +20,8 @@ jobs: with: python-version: '3.x' - name: Install tox - run: pip install tox==3.17.1 + run: | + pip install tox==3.17.1 + pip install . - name: Build docs run: tox -e docs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ea8a73ffb..e108ca6fa 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index dfc4dfcb7..ce003f928 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -20,6 +20,8 @@ jobs: with: python-version: '3.x' - name: Install tox - run: pip install tox==3.17.1 + run: | + pip install tox==3.17.1 + pip install . - name: Run style checks run: tox -e style diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14e57479a..c2871db6b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 3.8.3 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: diff --git a/setup.cfg b/setup.cfg index 22b1db081..f104e210b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,5 +31,7 @@ ignore = B904 # manual quoting B907 + # assertRaises-type + B908 # up to 88 allowed by bugbear B950 max-line-length = 80 diff --git a/setup.py b/setup.py index 04663e40a..72965d454 100644 --- a/setup.py +++ b/setup.py @@ -41,14 +41,16 @@ "ipython>=8.10.0;python_version>='3.8'", "pandas>=1.1.3", "py42>=1.26.0", + "setuptools>=66.0.0", ], extras_require={ "dev": [ - "flake8==3.8.3", + "flake8>=4.0.0", "pytest==4.6.11", "pytest-cov==2.10.0", "pytest-mock==2.0.0", "tox>=3.17.1", + "importlib-metadata<5.0", ], "docs": [ "sphinx==4.4.0", diff --git a/tests/logger/test_init.py b/tests/logger/test_init.py index b1948da93..640f67b94 100644 --- a/tests/logger/test_init.py +++ b/tests/logger/test_init.py @@ -77,23 +77,21 @@ def test_get_logger_for_server_when_given_cef_format_uses_cef_formatter(): logger = get_logger_for_server( "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None ) - assert type(logger.handlers[0].formatter) == FileEventDictToCEFFormatter + assert isinstance(logger.handlers[0].formatter, FileEventDictToCEFFormatter) def test_get_logger_for_server_when_given_json_format_uses_json_formatter(): logger = get_logger_for_server( "example.com", ServerProtocol.TCP, OutputFormat.JSON, None ) - actual = type(logger.handlers[0].formatter) - assert actual == FileEventDictToJSONFormatter + assert isinstance(logger.handlers[0].formatter, FileEventDictToJSONFormatter) def test_get_logger_for_server_when_given_raw_json_format_uses_raw_json_formatter(): logger = get_logger_for_server( "example.com", ServerProtocol.TCP, OutputFormat.RAW, None ) - actual = type(logger.handlers[0].formatter) - assert actual == FileEventDictToRawJSONFormatter + assert isinstance(logger.handlers[0].formatter, FileEventDictToRawJSONFormatter) def test_get_logger_for_server_when_called_twice_only_has_one_handler(): @@ -108,7 +106,7 @@ def test_get_logger_for_server_uses_no_priority_syslog_handler(): logger = get_logger_for_server( "example.com", ServerProtocol.TCP, SendToFileEventsOutputFormat.CEF, None ) - assert type(logger.handlers[0]) == NoPrioritySysLogHandler + assert isinstance(logger.handlers[0], NoPrioritySysLogHandler) def test_get_logger_for_server_constructs_handler_with_expected_args( diff --git a/tox.ini b/tox.ini index 9413bdf03..e8cea7f8d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{311,310,39,38,37} + py{312,311,310,39,38,37} docs style skip_missing_interpreters = true @@ -12,6 +12,7 @@ deps = pytest-cov == 4.0.0 pandas >= 1.1.3 pexpect == 4.8.0 + setuptools >= 66.0.0 commands = # -v: verbose From d7859ea9430d407388fdb001ac42227049573f7d Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:37:32 -0600 Subject: [PATCH 342/349] Chore/prep release 1.18.0 (#409) * prep 1.18.0 release * suppress pandas FutureWarning --- CHANGELOG.md | 6 ++++++ src/code42cli/__version__.py | 2 +- src/code42cli/output_formats.py | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 861005dc3..d5369176a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## 1.18.0 - 2023-11-30 + +### Added + +- Support for Python 3.12, includes various dependency version requirement updates. + ## 1.17.0 - 2023-08-04 ### Removed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 30244104a..6cea18d86 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.17.0" +__version__ = "1.18.0" diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index b0f87a259..114fb706a 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -1,6 +1,7 @@ import csv import io import json +import warnings from itertools import chain from typing import Generator @@ -16,6 +17,8 @@ from code42cli.util import find_format_width from code42cli.util import format_to_table +# remove this once we drop support for Python 3.7 +warnings.filterwarnings("ignore", category=FutureWarning) CEF_DEFAULT_PRODUCT_NAME = "Advanced Exfiltration Detection" CEF_DEFAULT_SEVERITY_LEVEL = "5" @@ -90,6 +93,8 @@ def _iter_table(self, dfs, columns=None, **kwargs): if df.empty: return # convert everything to strings so we can left-justify format + # applymap() is deprecated in favor of map() for pandas 2.0+ (method renamed) + # pandas only supports Python 3.8+, update this once we drop support for Python 3.7 df = df.fillna("").applymap(str) # set overrideable default kwargs kwargs = { From 16d245a51931ccbff3b4641a4bcbc9e0881a5a76 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Fri, 1 Dec 2023 09:36:06 -0600 Subject: [PATCH 343/349] fix nightly job (#410) --- .github/workflows/nightly.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e108ca6fa..2aa02626b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -28,7 +28,9 @@ jobs: ssh-agent -a $SSH_AUTH_SOCK > /dev/null ssh-add - <<< "${{ secrets.C42_EVENT_EXTRACTOR_PRIVATE_DEPLOY_KEY }}" - name: Install tox - run: pip install tox==3.17.1 + run: | + pip install tox==3.17.1 + pip install . - name: Run Unit tests env: SSH_AUTH_SOCK: /tmp/ssh_agent.sock From 42c5cbaca964d707657ba5e6711e52e97fb7fa74 Mon Sep 17 00:00:00 2001 From: Tora Kozic <81983309+tora-kozic@users.noreply.github.com> Date: Fri, 1 Dec 2023 12:03:01 -0600 Subject: [PATCH 344/349] update readthedocs build config (#411) --- .readthedocs.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index cc1c33d80..186d4e545 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,6 +5,17 @@ # Required version: 2 +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + # You can also specify other tool versions: + # nodejs: "20" + # rust: "1.70" + # golang: "1.20" + + # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py @@ -13,7 +24,6 @@ sphinx: formats: all python: - version: 3.7 install: - method: pip path: . From bdc19e4be5c4deb8f59e563e245984f37ea668aa Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:09:41 -0500 Subject: [PATCH 345/349] Integ 2841/user agent (#414) * fix tests, deprecate non-supported python versions, add supported python versions * update user-agent * workflows * docker compose * changelog * bump py42 version --- .github/workflows/build.yml | 4 ++-- .github/workflows/nightly.yml | 2 +- .github/workflows/publish.yml | 2 +- CHANGELOG.md | 10 ++++++++++ CONTRIBUTING.md | 8 ++++---- docs/conf.py | 4 ++-- setup.py | 17 +++++++++-------- src/code42cli/main.py | 5 +++-- src/code42cli/output_formats.py | 4 ---- tests/test_output_formats.py | 2 +- tox.ini | 8 ++++---- 11 files changed, 37 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9ec99b83..a8dc027d0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 @@ -54,6 +54,6 @@ jobs: - name: Install ncat run: sudo apt-get install ncat - name: Start up the mock servers - run: cd code42-mock-servers; docker-compose up -d --build + run: cd code42-mock-servers; docker compose up -d --build - name: Run the integration tests run: sleep 15; tox -e integration diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2aa02626b..38ace65fe 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 64645e65d..557793893 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v1 with: - python-version: '3.8' + python-version: '3.9' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/CHANGELOG.md b/CHANGELOG.md index d5369176a..e1f36d6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +## Changed + +- Updated the user-agent prefix for compatibility with Incydr conventions. + +## Removed + +- Removed support for end-of-life python versions 3.6, 3.7, 3.8. + ## 1.18.0 - 2023-11-30 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5527425d8..6ff27506e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,13 +50,13 @@ pyenv virtualenv 3.9.10 code42cli pyenv activate code42cli ``` -**Note**: The CLI supports pythons versions 3.6 through 3.9 for end users. However due to some of the build dependencies, you'll need a version >=3.7 for your virtual environment. Use `pyenv --versions` to see all versions available for install. There are some known issues installing python 3.6 with pyenv on certain OS. +**Note**: The CLI supports pythons versions 3.9 through 3.12 for end users. Use `pyenv --versions` to see all versions available for install. Use `source deactivate` to exit the virtual environment and `pyenv activate code42cli` to reactivate it. ### Windows/Linux -Install a version of python 3.6 or higher from [python.org](https://python.org). +Install a version of python 3.9 or higher from [python.org](https://python.org). Next, in a directory somewhere outside the project, create and activate your virtual environment: ```bash @@ -86,7 +86,7 @@ point to your virtual environment, and you should be ready to go! ## Run a full build -We use [tox](https://tox.readthedocs.io/en/latest/#) to run our build against Python 3.6, 3.7, and 3.8. When run locally, `tox` will run only against the version of python that your virtual envrionment is running, but all versions will be validated against when you [open a PR](#opening-a-pr). +We use [tox](https://tox.readthedocs.io/en/latest/#) to run our build against Python 3.9, 3.10, 3.11 and 3.12. When run locally, `tox` will run only against the version of python that your virtual envrionment is running, but all versions will be validated against when you [open a PR](#opening-a-pr). To run all the unit tests, do a test build of the documentation, and check that the code meets all style requirements, simply run: @@ -97,7 +97,7 @@ If the full process runs without any errors, your environment is set up correctl ## Coding Style -Use syntax and built-in modules that are compatible with Python 3.6+. +Use syntax and built-in modules that are compatible with Python 3.9+. ### Style linter diff --git a/docs/conf.py b/docs/conf.py index 94ced0098..87a5ab36f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,7 +43,7 @@ ] # Add myst_parser types to suppress warnings -suppress_warnings = ["myst.header"] +suppress_warnings = ["myst.header", "myst.xref_missing"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -61,7 +61,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +# language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/setup.py b/setup.py index 72965d454..694c4a7eb 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ package_dir={"": "src"}, include_package_data=True, zip_safe=False, - python_requires=">=3.6.2, <4", + python_requires=">=3.9, <4", install_requires=[ "chardet", "click>=7.1.1", @@ -40,7 +40,7 @@ "ipython>=7.16.3;python_version<'3.8'", "ipython>=8.10.0;python_version>='3.8'", "pandas>=1.1.3", - "py42>=1.26.0", + "py42>=1.27.2", "setuptools>=66.0.0", ], extras_require={ @@ -53,9 +53,9 @@ "importlib-metadata<5.0", ], "docs": [ - "sphinx==4.4.0", - "myst-parser==0.16", - "sphinx_rtd_theme==1.0.0", + "sphinx==8.1.3", + "myst-parser==4.0.0", + "sphinx_rtd_theme==3.0.2", "sphinx-click", ], }, @@ -65,9 +65,10 @@ "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", ], entry_points={"console_scripts": ["code42=code42cli.main:cli"]}, diff --git a/src/code42cli/main.py b/src/code42cli/main.py index 9be3d29fc..519d2d214 100644 --- a/src/code42cli/main.py +++ b/src/code42cli/main.py @@ -7,10 +7,11 @@ import click from click_plugins import with_plugins from pkg_resources import iter_entry_points -from py42.settings import set_user_agent_suffix +from py42.settings import set_user_agent_prefix from code42cli import BANNER from code42cli import PRODUCT_NAME +from code42cli.__version__ import __version__ from code42cli.click_ext.groups import ExceptionHandlingGroup from code42cli.cmds.alert_rules import alert_rules from code42cli.cmds.alerts import alerts @@ -39,7 +40,7 @@ def exit_on_interrupt(signal, frame): # Sets part of the user agent string that py42 attaches to requests for the purposes of # identifying CLI users. -set_user_agent_suffix(PRODUCT_NAME) +set_user_agent_prefix(f"{PRODUCT_NAME}/{__version__} (Code42; code42.com )") CONTEXT_SETTINGS = { "help_option_names": ["-h", "--help"], diff --git a/src/code42cli/output_formats.py b/src/code42cli/output_formats.py index 114fb706a..2b2ab51f1 100644 --- a/src/code42cli/output_formats.py +++ b/src/code42cli/output_formats.py @@ -1,7 +1,6 @@ import csv import io import json -import warnings from itertools import chain from typing import Generator @@ -17,9 +16,6 @@ from code42cli.util import find_format_width from code42cli.util import format_to_table -# remove this once we drop support for Python 3.7 -warnings.filterwarnings("ignore", category=FutureWarning) - CEF_DEFAULT_PRODUCT_NAME = "Advanced Exfiltration Detection" CEF_DEFAULT_SEVERITY_LEVEL = "5" diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index 4c98f95c6..d8295dac3 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -2,7 +2,7 @@ from collections import OrderedDict import pytest -from numpy import NaN +from numpy import nan as NaN from pandas import DataFrame import code42cli.output_formats as output_formats_module diff --git a/tox.ini b/tox.ini index e8cea7f8d..b69f3de95 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{312,311,310,39,38,37} + py{312,311,310,39} docs style skip_missing_interpreters = true @@ -25,9 +25,9 @@ commands = [testenv:docs] deps = - sphinx == 4.4.0 - myst-parser == 0.17.2 - sphinx_rtd_theme == 1.0.0 + sphinx == 8.1.3 + myst-parser == 4.0.0 + sphinx_rtd_theme == 3.0.2 sphinx-click whitelist_externals = bash From 310ced0c6e1c62e45905d4d988d104d3a2163a58 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:34:16 -0500 Subject: [PATCH 346/349] prep release 1.18.1 (#415) --- CHANGELOG.md | 2 +- src/code42cli/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f36d6fc..d7f686e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.18.1 - 2025-01-08 ## Changed diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 6cea18d86..4a7bff544 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.18.0" +__version__ = "1.18.1" From da3b2497c4da7214cf78aeeff20ade1f44ae9080 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:14:32 -0400 Subject: [PATCH 347/349] deprecate incydr functionality and remove guides (#416) * deprecate incydr functionality * specify python version for ci * remove failing test * fix deprecation text in alert rules * add link to deprecation text * changelog * remove duplicate warning --- .github/workflows/docs.yml | 2 +- .github/workflows/style.yml | 2 +- CHANGELOG.md | 6 + docs/commands/alertrules.rst | 2 + docs/commands/alerts.rst | 2 + docs/commands/auditlogs.rst | 2 + docs/commands/cases.rst | 2 + docs/commands/securitydata.rst | 4 +- docs/commands/trustedactivities.rst | 2 + docs/commands/watchlists.rst | 2 + docs/guides.md | 12 -- docs/userguides/alertrules.md | 110 ---------- docs/userguides/cases.md | 96 --------- docs/userguides/siemexample.md | 273 ------------------------ docs/userguides/trustedactivities.md | 74 ------- docs/userguides/v2apis.md | 187 ---------------- docs/userguides/watchlists.md | 76 ------- src/code42cli/cmds/alert_rules.py | 6 +- src/code42cli/cmds/alerts.py | 6 +- src/code42cli/cmds/auditlogs.py | 6 +- src/code42cli/cmds/cases.py | 6 +- src/code42cli/cmds/securitydata.py | 9 +- src/code42cli/cmds/trustedactivities.py | 6 +- src/code42cli/cmds/watchlists.py | 6 +- tests/cmds/test_auditlogs.py | 48 ++--- 25 files changed, 79 insertions(+), 868 deletions(-) delete mode 100644 docs/userguides/alertrules.md delete mode 100644 docs/userguides/cases.md delete mode 100644 docs/userguides/siemexample.md delete mode 100644 docs/userguides/trustedactivities.md delete mode 100644 docs/userguides/v2apis.md delete mode 100644 docs/userguides/watchlists.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 26262813d..89444967c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v1 with: - python-version: '3.x' + python-version: '3.11' - name: Install tox run: | pip install tox==3.17.1 diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index ce003f928..383e31961 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v1 with: - python-version: '3.x' + python-version: '3.11' - name: Install tox run: | pip install tox==3.17.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f686e66..bf77a00ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. +## Unreleased + +### Deprecated + +- All Incydr functionality is deprecated in Code42CLI. Use the Incydr SDK instead: https://developer.code42.com/ + ## 1.18.1 - 2025-01-08 ## Changed diff --git a/docs/commands/alertrules.rst b/docs/commands/alertrules.rst index d8f2507c5..cb0d90500 100644 --- a/docs/commands/alertrules.rst +++ b/docs/commands/alertrules.rst @@ -1,3 +1,5 @@ +.. warning:: Incydr functionality is **deprecated**. Use the Incydr CLI instead. + .. click:: code42cli.cmds.alert_rules:alert_rules :prog: alert-rules :nested: full diff --git a/docs/commands/alerts.rst b/docs/commands/alerts.rst index 4c39ea8bc..96c7eb826 100644 --- a/docs/commands/alerts.rst +++ b/docs/commands/alerts.rst @@ -1,3 +1,5 @@ +.. warning:: Incydr functionality is **deprecated**. Use the Incydr CLI instead. + .. click:: code42cli.cmds.alerts:alerts :prog: alerts :nested: full diff --git a/docs/commands/auditlogs.rst b/docs/commands/auditlogs.rst index 29eb0e462..d2d70f436 100644 --- a/docs/commands/auditlogs.rst +++ b/docs/commands/auditlogs.rst @@ -1,3 +1,5 @@ +.. warning:: Incydr functionality is **deprecated**. Use the Incydr CLI instead. + .. click:: code42cli.cmds.auditlogs:audit_logs :prog: audit-logs :nested: full diff --git a/docs/commands/cases.rst b/docs/commands/cases.rst index ac124f0a5..b2e5665ab 100644 --- a/docs/commands/cases.rst +++ b/docs/commands/cases.rst @@ -1,3 +1,5 @@ +.. warning:: Incydr functionality is **deprecated**. Use the Incydr CLI instead. + .. click:: code42cli.cmds.cases:cases :prog: cases :nested: full diff --git a/docs/commands/securitydata.rst b/docs/commands/securitydata.rst index f0eaa317c..15c37a73b 100644 --- a/docs/commands/securitydata.rst +++ b/docs/commands/securitydata.rst @@ -2,9 +2,7 @@ Security Data ************* -.. warning:: V1 file events, saved searches, and queries are **deprecated**. - -See more information in the `Enable V2 File Events User Guide <../userguides/v2apis.html>`_. +.. warning:: Incydr functionality is **deprecated**. Use the Incydr CLI instead. .. click:: code42cli.cmds.securitydata:security_data :prog: security-data diff --git a/docs/commands/trustedactivities.rst b/docs/commands/trustedactivities.rst index 67a114086..ff218d34e 100644 --- a/docs/commands/trustedactivities.rst +++ b/docs/commands/trustedactivities.rst @@ -1,3 +1,5 @@ +.. warning:: Incydr functionality is **deprecated**. Use the Incydr CLI instead. + .. click:: code42cli.cmds.trustedactivities:trusted_activities :prog: trusted-activities :nested: full diff --git a/docs/commands/watchlists.rst b/docs/commands/watchlists.rst index 1b48ba246..b52b462b0 100644 --- a/docs/commands/watchlists.rst +++ b/docs/commands/watchlists.rst @@ -1,3 +1,5 @@ +.. warning:: Incydr functionality is **deprecated**. Use the Incydr CLI instead. + .. click:: code42cli.cmds.watchlists:watchlists :prog: watchlists :nested: full diff --git a/docs/guides.md b/docs/guides.md index df4ddc01c..bbf07f09e 100644 --- a/docs/guides.md +++ b/docs/guides.md @@ -8,29 +8,17 @@ Get started with the Code42 command-line interface (CLI) Configure a profile - Enable V2 File Events - Ingest data into a SIEM Manage legal hold users Clean up your environment by deactivating devices Write custom extension scripts using the Code42 CLI and Py42 Manage users - Configure trusted activities - Configure alert rules - Add and manage cases Perform bulk actions - Manage watchlist members ``` * [Get started with the Code42 command-line interface (CLI)](userguides/gettingstarted.md) * [Configure a profile](userguides/profile.md) -* [Enable V2 File Events](userguides/v2apis.md) -* [Ingest data into a SIEM](userguides/siemexample.md) * [Manage legal hold users](userguides/legalhold.md) * [Clean up your environment by deactivating devices](userguides/deactivatedevices.md) * [Write custom extension scripts using the Code42 CLI and Py42](userguides/extensions.md) * [Manage users](userguides/users.md) -* [Configure trusted activities](userguides/trustedactivities.md) -* [Configure alert rules](userguides/alertrules.md) -* [Add and manage cases](userguides/cases.md) * [Perform bulk actions](userguides/bulkcommands.md) -* [Manage watchlist members](userguides/watchlists.md) diff --git a/docs/userguides/alertrules.md b/docs/userguides/alertrules.md deleted file mode 100644 index bc6462627..000000000 --- a/docs/userguides/alertrules.md +++ /dev/null @@ -1,110 +0,0 @@ -# Add Users to Alert Rules - -Once you [create an alert rule in the Code42 console](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Alert_rule_settings_reference), you can use the CLI `alert-rules` commands to add and remove users from your existing alert rules. - -To see a list of all the users currently in your organization: -- Export a list from the [Users action menu](https://support.code42.com/Administrator/Cloud/Code42_console_reference/Users_reference#Action_menu). -- Use the [CLI users commands](./users.md). - -## View Existing Alert Rules - -You'll need the ID of an alert rule to add or remove a user. - -To view a list of all alert rules currently created for your organization, including the rule ID, use the following command: -```bash -code42 alert-rules list -``` - -Once you've identified the rule ID, view the details of the alert rule as follows: -```bash -code42 alert-rules show -``` - -#### Example output -Example output for a single alert rule in default JSON format. -```json -{ - "type$": "ENDPOINT_EXFILTRATION_RULE_DETAILS_RESPONSE", - "rules": [ - { - "type$": "ENDPOINT_EXFILTRATION_RULE_DETAILS", - "tenantId": "c4e43418-07d9-4a9f-a138-29f39a124d33", - "name": "My Rule", - "description": "this is your rule!", - "severity": "HIGH", - "isEnabled": false, - "fileBelongsTo": { - "type$": "FILE_BELONGS_TO", - "usersToAlertOn": "ALL_USERS" - }, - "notificationConfig": { - "type$": "NOTIFICATION_CONFIG", - "enabled": false - }, - "fileCategoryWatch": { - "type$": "FILE_CATEGORY_WATCH", - "watchAllFiles": true - }, - "ruleSource": "Alerting", - "fileSizeAndCount": { - "type$": "FILE_SIZE_AND_COUNT", - "fileCountGreaterThan": 2, - "totalSizeGreaterThanInBytes": 200, - "operator": "AND" - }, - "fileActivityIs": { - "type$": "FILE_ACTIVITY", - "syncedToCloudService": { - "type$": "SYNCED_TO_CLOUD_SERVICE", - "watchBox": false, - "watchBoxDrive": false, - "watchDropBox": false, - "watchGoogleBackupAndSync": false, - "watchAppleIcLoud": false, - "watchMicrosoftOneDrive": false - }, - "uploadedOnRemovableMedia": true, - "readByBrowserOrOther": true - }, - "timeWindow": 15, - "id": "404ff012-fa2f-4acf-ae6d-107eabf7f24c", - "createdAt": "2021-04-27T01:55:36.4204590Z", - "createdBy": "sean.cassidy@example.com", - "modifiedAt": "2021-09-03T01:46:13.2902310Z", - "modifiedBy": "sean.cassidy@example.com", - "isSystem": false - } - ] -} -``` - -## Add a User to an Alert Rule - -You can manage the users who are associated with an alert rule once you know the rule's `rule_id` and the user's `username`. - -To add a single user to your alert rule, use the following command: -```bash -code42 alert-rules add-user --rule-id -u sean.cassidy@example.com -``` - -Alternatively, to add multiple users to your alert rule, fill out the `add` CSV file template, then use the `bulk add` command with the CSV file path. -```bash -code42 alert-rules bulk add users.csv -``` - -You can remove single or multiple users from alert rules similarly using the `remove-user` and `bulk remove` commands. - - -## Get CSV Template - -The following command will generate a CSV template to either add or remove users from multiple alert rules at once. The CSV file will be saved to the current working directory. -```bash -code42 alert-rules bulk generate-template [add|remove] -``` - -You can then fill out and use each of the CSV templates with their respective bulk commands. -```bash -code42 alert-rules bulk [add|remove] /Users/my_user/bulk-command.csv -``` - -Learn more about the [Alert Rules](../commands/alertrules.md) commands. diff --git a/docs/userguides/cases.md b/docs/userguides/cases.md deleted file mode 100644 index 06f72e057..000000000 --- a/docs/userguides/cases.md +++ /dev/null @@ -1,96 +0,0 @@ -# Add and Manage Cases - -To create a new case, only the name is required. Other attributes are optional and can be provided through the available flags. - -The following command creates a case with the `subject` and `assignee` user indicated by their respective UIDs. -```bash -code42 cases create My-Case --subject 123 --assignee 456 --description "Sample case" -``` - -## Update a Case - -To further update or view the details of your case, you'll need the case's unique number, which is assigned upon creation. To get this number, you can use the `list` command to view all cases, with optional filter values. - -To print to the console all open cases created in the last 30 days: -```bash -code42 cases list --begin-create-time 30d --status OPEN -``` - -#### Example Output -Example output for a single case in JSON format. -```json -{ - "number": 42, - "name": "My-Case", - "createdAt": "2021-9-17T18:29:53.375136Z", - "updatedAt": "2021-9-17T18:29:53.375136Z", - "description": "Sample case", - "findings": "", - "subject": "123", - "subjectUsername": "sean.cassidy@example.com", - "status": "OPEN", - "assignee": "456", - "assigneeUsername": "elvis.presley@example.com", - "createdByUserUid": "789", - "createdByUsername": "andy.warhol@example.com", - "lastModifiedByUserUid": "789", - "lastModifiedByUsername": "andy.warhol@example.com" -} -``` - -Once you've identified your case's number, you can view further details on the case, or update its attributes. - -The following command will print all details of your case. -```bash -code42 cases show 42 -``` - -If you've finished your investigation and you'd like to close your case, you can update the status of the case. Similarly, other attributes of the case can be updated using the optional flags. -```bash -code42 cases update 42 --status CLOSED -``` - -## Get CSV Template - -The following command will generate a CSV template to either add or remove file events from multiple cases at once. The csv file will be saved to the current working directory. -```bash -code42 cases file-events bulk generate-template [add|remove] -``` - -You can then fill out and use each of the CSV templates with their respective bulk commands. -```bash -code42 cases file-events bulk [add|remove] bulk-command.csv -``` - -## Manage File Exposure Events Associated with a Case - -The following example command can be used to view all the file exposure events currently associated with a case, indicated here by case number `42`. -```bash -code42 cases file-events list 42 -``` - -Use the `file-events add` command to associate a single file event, referred to by event ID, to a case. - -Below is an example command to associate some event with ID `event_abc` with case number `42`. -```bash -code42 cases file-events add 42 event_abc -``` - -To associate multiple file events with one or more cases at once, enter the case and file event information into the `file-events add` CSV file template, then use the `bulk add` command with the CSV file path. For example: -```bash -code42 cases file-events bulk add my_new_cases.csv -``` - -Similarly, the `file-events remove` and `file-events bulk remove` commands can be used to remove a file event from a case. - -## Export Case Details - -You can use the CLI to export the details of a case into a PDF. - -The following example command will download the details from case number `42` and save a PDF with the name `42_case_summary.pdf` to the provided path. If a path is not provided, it will be saved to the current working directory. - -```bash -code42 cases export 42 --path /Users/my_user/cases/ -``` - -Learn more about the [Managing Cases](../commands/cases.md). diff --git a/docs/userguides/siemexample.md b/docs/userguides/siemexample.md deleted file mode 100644 index 4cfdbfa87..000000000 --- a/docs/userguides/siemexample.md +++ /dev/null @@ -1,273 +0,0 @@ -# Ingest file event data or alerts into a SIEM tool - -This guide provides instructions on using the CLI to ingest Code42 file event data or alerts -into a security information and event management (SIEM) tool like LogRhythm, Sumo Logic, or IBM QRadar. - -## Considerations - -To ingest file events or alerts into a SIEM tool using the Code42 command-line interface, the Code42 user account running the integration -must be assigned roles that provide the necessary permissions. - -The CEF format is not recommended because it was not designed for insider risk event data. Code42 file event data contains many fields that provide valuable insider risk context that have no CEF equivalent. However, if you need to use CEF, the JSON-to-CEF mapping at the bottom of this document indicates which fields are included and how the field names map to other formats. - -## Before you begin - -First install and configure the Code42 CLI following the instructions in -[Getting Started](gettingstarted.md). - -## Run queries -You can get file events in either a JSON or CEF format for use by your SIEM tool. Alerts data and audit logs are available in JSON format. You can query the data as a -scheduled job or run ad-hoc queries. - -Learn more about searching [File Events](../commands/securitydata.md), [Alerts](../commands/alerts.md), and [Audit Logs](../commands/auditlogs.md) using the CLI. - -### Run a query as a scheduled job - -Use your favorite scheduling tool, such as cron or Windows Task Scheduler, to run a query on a regular basis. Specify -the profile to use by including `--profile`. - -#### File Exposure Events -An example using the `send-to` command to forward only the new file event data since the previous request to an external syslog server: -```bash -code42 security-data send-to syslog.example.com:514 -p UDP --profile profile1 -c syslog_sender -``` -#### Alerts -An example to send to the syslog server only the new alerts that meet the filter criteria since the previous request: -```bash -code42 alerts send-to syslog.example.com:514 -p UDP --profile profile1 --rule-name "Source code exfiltration" --state OPEN -i -``` -#### Audit Logs -An example to send to the syslog server only the audit log events that meet the filter criteria from the last 30 days. -```bash -code42 audit-logs send-to syslog.example.com:514 -p UDP --profile profile1 --actor-username 'sean.cassidy@example.com' -b 30d -``` - -As a best practice, use a separate profile when executing a scheduled task. Using separate profiles can help prevent accidental updates to your stored checkpoints, for example, by adding `--use-checkpoint` to adhoc queries. - -### Run an ad-hoc query - -Examples of ad-hoc queries you can run are as follows. - -#### File Exposure Events - -Print file events since March 5 for a user in raw JSON format: -```bash -code42 security-data search -f RAW-JSON -b 2020-03-05 --c42-username 'sean.cassidy@example.com' -``` - -Print file events since March 5 where a file was synced to a cloud service: -```bash -code42 security-data search -t CloudStorage -b 2020-03-05 -``` - -Write to a text file the file events in raw JSON format where a file was read by browser or other app for a user since -March 5: -```bash -code42 security-data search -f RAW-JSON -b 2020-03-05 -t ApplicationRead --c42-username 'sean.cassidy@example.com' > /Users/sangita.maskey/Downloads/c42cli_output.txt -``` -#### Alerts -Print alerts since May 5 where a file's cloud share permissions changed: -```bash -code42 alerts print -b 2020-05-05 --rule-type FedCloudSharePermissions -``` -#### Audit Logs -Print audit log events since June 5 which affected a certain user: -```bash -code42 audit-logs search -b 2021-06-05 --affected-username 'sean.cassidy@examply.com' -``` - -#### Example Outputs - -Example output for a single file exposure event (in default JSON format): - -```json -{ - "eventId": "0_c4b5e830-824a-40a3-a6d9-345664cfbb33_942704829036142720_944009394534374185_342", - "eventType": "CREATED", - "eventTimestamp": "2020-03-05T14:45:49.662Z", - "insertionTimestamp": "2020-03-05T15:10:47.930Z", - "filePath": "C:/Users/sean.cassidy/Google Drive/", - "fileName": "1582938269_Longfellow_Cloud_Arch_Redesign.drawio", - "fileType": "FILE", - "fileCategory": "DOCUMENT", - "fileSize": 6025, - "fileOwner": "Administrators", - "md5Checksum": "9ab754c9133afbf2f70d5fe64cde1110", - "sha256Checksum": "8c6ba142065373ae5277ecf9f0f68ab8f9360f42a82eb1dec2e1816d93d6b1b7", - "createTimestamp": "2020-03-05T14:29:33.455Z", - "modifyTimestamp": "2020-02-29T01:04:31Z", - "deviceUserName": "sean.cassidy@example.com", - "osHostName": "LAPTOP-091", - "domainName": "192.168.65.129", - "publicIpAddress": "71.34.10.80", - "privateIpAddresses": [ - "fe80:0:0:0:8d61:ec3f:9e32:2efc%eth2", - "192.168.65.129", - "0:0:0:0:0:0:0:1", - "127.0.0.1" - ], - "deviceUid": "942704829036142720", - "userUid": "887050325252344565", - "source": "Endpoint", - "exposure": [ - "CloudStorage" - ], - "syncDestination": "GoogleBackupAndSync" -} -``` -Example output for a single alert (in default JSON format): - -```json -{ - "type$": "ALERT_DETAILS", - "tenantId": "c4b5e830-824a-40a3-a6d9-345664cfbb33", - "type": "FED_CLOUD_SHARE_PERMISSIONS", - "name": "Cloud Share", - "description": "Alert Rule for data exfiltration via Cloud Share", - "actor": "leland.stewart@example.com", - "target": "N/A", - "severity": "HIGH", - "ruleId": "408eb1ae-587e-421a-9444-f75d5399eacb", - "ruleSource": "Alerting", - "id": "7d936d0d-e783-4b24-817d-f19f625e0965", - "createdAt": "2020-05-22T09:47:33.8863230Z", - "state": "OPEN", - "observations": [{"type$": "OBSERVATION", - "id": "4bc378e6-bfbd-40f0-9572-6ed605ea9f6c", - "observedAt": "2020-05-22T09:40:00.0000000Z", - "type": "FedCloudSharePermissions", - "data": { - "type$": "OBSERVED_CLOUD_SHARE_ACTIVITY", - "id": "4bc378e6-bfbd-40f0-9572-6ed605ea9f6c", - "sources": ["GoogleDrive"], - "exposureTypes": ["PublicLinkShare"], - "firstActivityAt": "2020-05-22T09:40:00.0000000Z", - "lastActivityAt": "2020-05-22T09:45:00.0000000Z", - "fileCount": 1, - "totalFileSize": 6025, - "fileCategories": [{"type$": "OBSERVED_FILE_CATEGORY", "category": "Document", "fileCount": 1, "totalFileSize": 6025, "isSignificant": false}], - "files": [{"type$": "OBSERVED_FILE", "eventId": "1hHdK6Qe6hez4vNCtS-UimDf-sbaFd-D7_3_baac33d0-a1d3-4e0a-9957-25632819eda7", "name": "1590140395_Longfellow_Cloud_Arch_Redesign.drawio", "category": "Document", "size": 6025}], - "outsideTrustedDomainsEmailsCount": 0, "outsideTrustedDomainsTotalDomainCount": 0, "outsideTrustedDomainsTotalDomainCountTruncated": false}}] -} -``` - -Example output for a single audit log event (in default JSON format): -```json -{ - "type$": "audit_log::logged_in/1", - "actorId": "1015070955620029617", - "actorName": "sean.cassidy@example.com", - "actorAgent": "py42 1.17.0 python 3.7.10", - "actorIpAddress": "67.220.16.122", - "timestamp": "2021-08-30T16:16:19.165Z", - "actorType": "USER" -} -``` - - -## CEF Mapping - -The following tables map the file event data from the Code42 CLI to common event format (CEF). - -### Attribute mapping - -The table below maps JSON fields, CEF fields, and [Forensic Search fields](https://code42.com/r/support/forensic-search-fields) -to one another. - -```{eval-rst} - -+----------------------------+---------------------------------+----------------------------------------+ -| JSON field | CEF field | Forensic Search field | -+============================+=================================+========================================+ -| actor | suser | Actor | -+----------------------------+---------------------------------+----------------------------------------+ -| cloudDriveId | aid | n/a | -+----------------------------+---------------------------------+----------------------------------------+ -| createTimestamp | fileCreateTime | File Created Date | -+----------------------------+---------------------------------+----------------------------------------+ -| deviceUid | deviceExternalId | n/a | -+----------------------------+---------------------------------+----------------------------------------+ -| deviceUserName | suser | Username (Code42) | -+----------------------------+---------------------------------+----------------------------------------+ -| domainName | dvchost | Fully Qualified Domain Name | -+----------------------------+---------------------------------+----------------------------------------+ -| eventId | externalID | n/a | -+----------------------------+---------------------------------+----------------------------------------+ -| eventTimestamp | end | Date Observed | -+----------------------------+---------------------------------+----------------------------------------+ -| exposure | reason | Exposure Type | -+----------------------------+---------------------------------+----------------------------------------+ -| fileCategory | fileType | File Category | -+----------------------------+---------------------------------+----------------------------------------+ -| fileName | fname | Filename | -+----------------------------+---------------------------------+----------------------------------------+ -| filePath | filePath | File Path | -+----------------------------+---------------------------------+----------------------------------------+ -| fileSize | fsize | File Size | -+----------------------------+---------------------------------+----------------------------------------+ -| insertionTimestamp | rt | n/a | -+----------------------------+---------------------------------+----------------------------------------+ -| md5Checksum | fileHash | MD5 Hash | -+----------------------------+---------------------------------+----------------------------------------+ -| modifyTimestamp | fileModificationTime | File Modified Date | -+----------------------------+---------------------------------+----------------------------------------+ -| osHostName | shost | Hostname | -+----------------------------+---------------------------------+----------------------------------------+ -| processName | sproc | Executable Name (Browser or Other App) | -+----------------------------+---------------------------------+----------------------------------------+ -| processOwner | spriv | Process User (Browser or Other App) | -+----------------------------+---------------------------------+----------------------------------------+ -| publiclpAddress | src | IP Address (public) | -+----------------------------+---------------------------------+----------------------------------------+ -| removableMediaBusType | cs1, | Device Bus Type (Removable Media) | -| | Code42AEDRemovableMediaBusType | | -+----------------------------+---------------------------------+----------------------------------------+ -| removableMediaCapacity | cn1, | Device Capacity (Removable Media) | -| | Code42AEDRemovableMediaCapacity | | -+----------------------------+---------------------------------+----------------------------------------+ -| removableMediaName | cs3, | Device Media Name (Removable Media) | -| | Code42AEDRemovableMediaName | | -+----------------------------+---------------------------------+----------------------------------------+ -| removableMediaSerialNumber | cs4 | Device Serial Number (Removable Media) | -+----------------------------+---------------------------------+----------------------------------------+ -| removableMediaVendor | cs2, | Device Vendor (Removable Media) | -| | Code42AEDRemovableMediaVendor | | -+----------------------------+---------------------------------+----------------------------------------+ -| sharedWith | duser | Shared With | -+----------------------------+---------------------------------+----------------------------------------+ -| syncDestination | destinationServiceName | Sync Destination (Cloud) | -+----------------------------+---------------------------------+----------------------------------------+ -| url | filePath | URL | -+----------------------------+---------------------------------+----------------------------------------+ -| userUid | suid | n/a | -+----------------------------+---------------------------------+----------------------------------------+ -| windowTitle | requestClientApplication | Tab/Window Title | -+----------------------------+---------------------------------+----------------------------------------+ -| tabUrl | request | Tab URL | -+----------------------------+---------------------------------+----------------------------------------+ -| emailSender | suser | Sender | -+----------------------------+---------------------------------+----------------------------------------+ -| emailRecipients | duser | Recipients | -+----------------------------+---------------------------------+----------------------------------------+ -``` - -### Event mapping - -See the table below to map file events to CEF signature IDs. - -```{eval-rst} - -+--------------------+-----------+ -| Exfiltration event | CEF field | -+====================+===========+ -| CREATED | C42200 | -+--------------------+-----------+ -| MODIFIED | C42201 | -+--------------------+-----------+ -| DELETED | C42202 | -+--------------------+-----------+ -| READ_BY_APP | C42203 | -+--------------------+-----------+ -| EMAILED | C42204 | -+--------------------+-----------+ -``` diff --git a/docs/userguides/trustedactivities.md b/docs/userguides/trustedactivities.md deleted file mode 100644 index a40daa6fe..000000000 --- a/docs/userguides/trustedactivities.md +++ /dev/null @@ -1,74 +0,0 @@ -# Configure Trusted Activities - -You can add trusted activities to your organization to prevent file activity associated with these locations from appearing in your security event dashboards, user profiles, and alerts. - -## Get CSV Template - -The following command generates a CSV template to either create, update, or remove multiple trusted activities at once. The CSV file is saved to the current working directory. -```bash -code42 trusted-activities bulk generate-template [create|update|remove] -``` - -You can then fill out and use each of the CSV templates with their respective bulk commands. -```bash -code42 trusted-activities bulk [create|update|remove] bulk-command.csv -``` - -## Add a New Trusted Activity - -Use the `create` command to add a new trusted domain or Slack workspace to your organization's trusted activities. -```bash -code42 trusted-activities create DOMAIN mydomain.com --description "a new trusted activity" -``` - -To add multiple trusted activities at once, enter information about the trusted activity into the `create` CSV file template. -For each activity, the `type` and `value` fields are required. - - `type` indicates the category of activity: - - `DOMAIN` indicates a trusted domain - - `SLACK` indicates a trusted Slack workspace - - `value` indicates either the name of the domain or Slack workspace. - -Then use the `bulk create` command with the CSV file path. For example: -```bash -code42 trusted-activities bulk create create_trusted_activities.csv -``` - -## Update a Trusted Activity - -Use the `update` command to update either the value or description of a single trusted activity. The `resource_id` of the activity is required. The other fields are optional. - -```bash -code42 trusted-activities update 123 --value my-updated-domain.com --description "an updated trusted activity" -``` - -To update multiple trusted activities at once, enter information about the trusted activity into the `update` CSV file template, then use the `bulk update` command with the CSV file path. - -```bash -code42 trusted-activities bulk update update_trusted_activities.csv -``` - -```{eval-rst} -.. note:: - The ``bulk update`` command cannot be used to clear the description of a trusted activity because you cannot indicate an empty string in a CSV format. - Pass an empty string to the ``description`` option of the ``update`` command to clear the description of a trusted activity. - - For example: ``code42 trusted-activities update 123 --description ""`` -``` - -## Remove a Trusted Activity - -Use the `remove` command to remove a single trusted activity. Only the `resource_id` of an activity is required to remove it. - -```bash -code42 trusted-activities remove 123 -``` - -To remove multiple trusted activities at once, enter information about the trusted activity into the `remove` CSV file template, then use the `bulk remove` command with the CSV file path. - -```bash -code42 trusted-activities bulk remove remove_trusted_activities.csv -``` - -Learn more about the [Trusted Activities](../commands/trustedactivities.md) commands. diff --git a/docs/userguides/v2apis.md b/docs/userguides/v2apis.md deleted file mode 100644 index 59366a15d..000000000 --- a/docs/userguides/v2apis.md +++ /dev/null @@ -1,187 +0,0 @@ -# V2 File Events - -```{eval-rst} -.. warning:: V1 file events, saved searches, and queries are **deprecated**. -``` - -For details on the updated File Event Model, see the V2 File Events API documentation on the [Developer Portal](https://developer.code42.com/api/#tag/File-Events). - -V1 file event APIs were marked deprecated in May 2022 and will be no longer be supported after May 2023. - -Use the `--use-v2-file-events True` option with the `code42 profile create` or `code42 profile update` commands to enable your code42 CLI profile to use the latest V2 file event data model. - -Use `code42 profile show` to check the status of this setting on your profile: - -```bash -% code42 profile update --use-v2-file-events True - -% code42 profile show - -test-user-profile: - * username = test-user@code42.com - * authority url = https://console.core-int.cloud.code42.com - * ignore-ssl-errors = False - * use-v2-file-events = True - -``` - -For details on setting up a profile, see the [profile set up user guide](./profile.md). - -Enabling this setting will use the V2 data model for querying searches and saved searches with all `code security-data` commands. -The response shape for these events has changed from V1 and contains various field remappings, renamings, additions and removals. Column names will also be different when using the `Table` format for outputting events. - -### V2 File Event Data Example ### - -Below is an example of the new file event data model: - -```json -{ - "@timestamp": "2022-07-14T16:53:06.112Z", - "event": { - "id": "0_c4e43418-07d9-4a9f-a138-29f39a124d33_1068825680073059134_1068826271084047166_1_EPS", - "inserted": "2022-07-14T16:57:00.913917Z", - "action": "application-read", - "observer": "Endpoint", - "shareType": [], - "ingested": "2022-07-14T16:55:04.723Z", - "relatedEvents": [] - }, - "user": { - "email": "engineer@example.com", - "id": "1068824450489230065", - "deviceUid": "1068825680073059134" - }, - "file": { - "name": "cat.jpg", - "directory": "C:/Users/John Doe/Downloads/", - "category": "Spreadsheet", - "mimeTypeByBytes": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "categoryByBytes": "Spreadsheet", - "mimeTypeByExtension": "image/jpeg", - "categoryByExtension": "Image", - "sizeInBytes": 4748, - "owner": "John Doe", - "created": "2022-07-14T16:51:06.186Z", - "modified": "2022-07-14T16:51:07.419Z", - "hash": { - "md5": "8872dfa1c181b823d2c00675ae5926fd", - "sha256": "14d749cce008711b4ad1381d84374539560340622f0e8b9eb2fe3bba77ddbd64", - "md5Error": null, - "sha256Error": null - }, - "id": null, - "url": null, - "directoryId": [], - "cloudDriveId": null, - "classifications": [] - }, - "report": { - "id": null, - "name": null, - "description": null, - "headers": [], - "count": null, - "type": null - }, - "source": { - "category": "Device", - "name": "DESKTOP-1", - "domain": "192.168.00.000", - "ip": "50.237.00.00", - "privateIp": [ - "192.168.00.000", - "127.0.0.1" - ], - "operatingSystem": "Windows 10", - "email": { - "sender": null, - "from": null - }, - "removableMedia": { - "vendor": null, - "name": null, - "serialNumber": null, - "capacity": null, - "busType": null, - "mediaName": null, - "volumeName": [], - "partitionId": [] - }, - "tabs": [], - "domains": [] - }, - "destination": { - "category": "Cloud Storage", - "name": "Dropbox", - "user": { - "email": [] - }, - "ip": null, - "privateIp": [], - "operatingSystem": null, - "printJobName": null, - "printerName": null, - "printedFilesBackupPath": null, - "removableMedia": { - "vendor": null, - "name": null, - "serialNumber": null, - "capacity": null, - "busType": null, - "mediaName": null, - "volumeName": [], - "partitionId": [] - }, - "email": { - "recipients": null, - "subject": null - }, - "tabs": [ - { - "title": "Files - Dropbox and 1 more page - Profile 1 - Microsoft​ Edge", - "url": "https://www.dropbox.com/home", - "titleError": null, - "urlError": null - } - ], - "accountName": null, - "accountType": null, - "domains": [ - "dropbox.com" - ] - }, - "process": { - "executable": "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", - "owner": "John doe" - }, - "risk": { - "score": 17, - "severity": "CRITICAL", - "indicators": [ - { - "name": "First use of destination", - "weight": 3 - }, - { - "name": "File mismatch", - "weight": 9 - }, - { - "name": "Spreadsheet", - "weight": 0 - }, - { - "name": "Remote", - "weight": 0 - }, - { - "name": "Dropbox upload", - "weight": 5 - } - ], - "trusted": false, - "trustReason": null - } -} - -``` diff --git a/docs/userguides/watchlists.md b/docs/userguides/watchlists.md deleted file mode 100644 index b269a1961..000000000 --- a/docs/userguides/watchlists.md +++ /dev/null @@ -1,76 +0,0 @@ -# Manage watchlist members - -## List created watchlists - -To list all the watchlists active in your Code42 environment, run: - -```bash -code42 watchlists list -``` - -## List all members of a watchlist - -You can list watchlists either by their Type: - -```bash -code42 watchlists list-members --watchlist-type DEPARTING_EMPLOYEE -``` - -or by their ID (get watchlist IDs from `code42 watchlist list` output): - -```bash -code42 watchlists list-members --watchlist-id 6e6c5acc-2568-4e5f-8324-e73f2811fa7c -``` - -A "member" of a watchlist is any user that the watchlist alerting rules apply to. Users can be members of a watchlist -either by being explicitly added (via console or `code42 watchlists add [USER_ID|USERNAME]`), but they can also be -implicitly included based on some user profile property (like working in a specific department). To get a list of only -those "members" who have been explicitly added (and thus can be removed via the `code42 watchlists remove [USER_ID|USERNAME]` -command), add the `--only-included-users` option to `list-members`. - -## Add or remove a single user from watchlist membership - -A user can be added to a watchlist using either the watchlist ID or Type, just like listing watchlists, and the user -can be identified either by their user_id or their username: - -```bash -code42 watchlist add --watchlist-type NEW_EMPLOYEE 9871230 -``` - -```bash -code42 watchlist add --watchlist-id 6e6c5acc-2568-4e5f-8324-e73f2811fa7c user@example.com -``` - -## Bulk adding/removing users from watchlists - -The bulk watchlist commands read input from a CSV file. - -Like the individual commands, they can take either a user_id/username or watchlist_id/watchlist_type to identify who -to add to which watchlist. Because of this flexibility, the CSV does require a header row identifying each column. - -You can generate a template CSV with the correct header values using the command: - -```bash -code42 watchlists bulk generate-template [add|remove] -``` - -If both username and user_id are provided in the CSV row, the user_id value will take precedence. If watchlist_type and watchlist_id columns -are both provided, the watchlist_id will take precedence. - -```{eval-rst} -.. note:: - - For watchlists that track additional metadata for a user (e.g. the "departure date" for a user on the Departing watchlist), that data - can be added/updated via the `code42 users bulk update-risk-profile <../commands/users.html#users-bulk-update-risk-profile>`_ command. - - You can re-use the same CSV file for both commands, just add the required risk profile columns to the CSV. - - For example, to bulk add users to multiple watchlists, with appropriate ``start_date``, ``end_date``, and ``notes`` values, create a CSV (in this example named ``watchlists.csv``) with the following:: - - username,watchlist_type,start_date,end_date,notes - user_a@example.com,DEPARTING_EMPLOYEE,,2023-10-10, - user_b@example.com,NEW_EMPLOYEE,2022-07-04,,2022 Summer Interns - - Then run ``code42 watchlists bulk add watchlists.csv`` - followed by ``code42 users bulk update-risk-profile watchlists.csv`` -``` diff --git a/src/code42cli/cmds/alert_rules.py b/src/code42cli/cmds/alert_rules.py index 0e034eedc..294bdf614 100644 --- a/src/code42cli/cmds/alert_rules.py +++ b/src/code42cli/cmds/alert_rules.py @@ -15,6 +15,9 @@ from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import OutputFormatter +from code42cli.util import deprecation_warning + +DEPRECATION_TEXT = "Incydr functionality is deprecated. Use the Incydr CLI instead." class AlertRuleTypes: @@ -35,7 +38,8 @@ class AlertRuleTypes: @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def alert_rules(state): - """Manage users associated with alert rules.""" + """DEPRECATED - Manage users associated with alert rules.""" + deprecation_warning(DEPRECATION_TEXT) pass diff --git a/src/code42cli/cmds/alerts.py b/src/code42cli/cmds/alerts.py index 6d90ac031..314e9d764 100644 --- a/src/code42cli/cmds/alerts.py +++ b/src/code42cli/cmds/alerts.py @@ -26,10 +26,13 @@ from code42cli.file_readers import read_csv_arg from code42cli.options import format_option from code42cli.output_formats import OutputFormatter +from code42cli.util import deprecation_warning from code42cli.util import hash_event from code42cli.util import parse_timestamp from code42cli.util import warn_interrupt +DEPRECATION_TEXT = "Incydr functionality is deprecated. Use the Incydr CLI instead (https://developer.code42.com/)." + ALERTS_KEYWORD = "alerts" ALERT_PAGE_SIZE = 25 @@ -194,7 +197,8 @@ def filter_options(f): @click.group(cls=OrderedGroup) @opt.sdk_options(hidden=True) def alerts(state): - """Get and send alert data.""" + """DEPRECATED - Get and send alert data.""" + deprecation_warning(DEPRECATION_TEXT) # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_alert_cursor_store diff --git a/src/code42cli/cmds/auditlogs.py b/src/code42cli/cmds/auditlogs.py index 68f843cdc..0671cde74 100644 --- a/src/code42cli/cmds/auditlogs.py +++ b/src/code42cli/cmds/auditlogs.py @@ -10,10 +10,13 @@ from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import OutputFormatter +from code42cli.util import deprecation_warning from code42cli.util import hash_event from code42cli.util import parse_timestamp from code42cli.util import warn_interrupt +DEPRECATION_TEXT = "Incydr functionality is deprecated. Use the Incydr CLI instead (https://developer.code42.com/)." + EVENT_KEY = "events" AUDIT_LOGS_KEYWORD = "audit-logs" @@ -90,7 +93,8 @@ def filter_options(f): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def audit_logs(state): - """Get and send audit log event data.""" + """DEPRECATED - Get and send audit log event data.""" + deprecation_warning(DEPRECATION_TEXT) # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_audit_log_cursor_store diff --git a/src/code42cli/cmds/cases.py b/src/code42cli/cmds/cases.py index 99e518af1..199cb7d18 100644 --- a/src/code42cli/cmds/cases.py +++ b/src/code42cli/cmds/cases.py @@ -18,6 +18,9 @@ from code42cli.options import set_begin_default_dict from code42cli.options import set_end_default_dict from code42cli.output_formats import OutputFormatter +from code42cli.util import deprecation_warning + +DEPRECATION_TEXT = "Incydr functionality is deprecated. Use the Incydr CLI instead (https://developer.code42.com/)." case_number_arg = click.argument("case-number", type=int) @@ -74,7 +77,8 @@ def _get_events_header(): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def cases(state): - """Manage cases and events associated with cases.""" + """DEPRECATED - Manage cases and events associated with cases.""" + deprecation_warning(DEPRECATION_TEXT) pass diff --git a/src/code42cli/cmds/securitydata.py b/src/code42cli/cmds/securitydata.py index 0a0a2b777..eae94d3f7 100644 --- a/src/code42cli/cmds/securitydata.py +++ b/src/code42cli/cmds/securitydata.py @@ -40,10 +40,11 @@ logger = get_main_cli_logger() MAX_EVENT_PAGE_SIZE = 10000 -DEPRECATION_TEXT = "(DEPRECATED): V1 file events are deprecated. Update your profile with `code42 profile update --use-v2-file-events True` to use the new V2 file event data model." SECURITY_DATA_KEYWORD = "file events" +DEPRECATION_TEXT = "Incydr functionality is deprecated. Use the Incydr CLI instead (https://developer.code42.com/)." + def exposure_type_callback(): def callback(ctx, param, arg): @@ -375,7 +376,8 @@ def file_event_options(f): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def security_data(state): - """Get and send file event data.""" + """DEPRECATED - Get and send file event data.""" + deprecation_warning(DEPRECATION_TEXT) # store cursor getter on the group state so shared --begin option can use it in validation state.cursor_getter = _get_file_event_cursor_store @@ -410,9 +412,6 @@ def search( ): """Search for file events.""" - if state.profile.use_v2_file_events != "True": - deprecation_warning(DEPRECATION_TEXT) - if format == FileEventsOutputFormat.CEF and columns: raise click.BadOptionUsage( "columns", "--columns option can't be used with CEF format." diff --git a/src/code42cli/cmds/trustedactivities.py b/src/code42cli/cmds/trustedactivities.py index 342772d20..95f3c477f 100644 --- a/src/code42cli/cmds/trustedactivities.py +++ b/src/code42cli/cmds/trustedactivities.py @@ -9,6 +9,9 @@ from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import OutputFormatter +from code42cli.util import deprecation_warning + +DEPRECATION_TEXT = "Incydr functionality is deprecated. Use the Incydr CLI instead (https://developer.code42.com/)." resource_id_arg = click.argument("resource-id", type=int) type_option = click.option( @@ -40,7 +43,8 @@ def _get_trust_header(): @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def trusted_activities(state): - """Manage trusted activities and resources.""" + """DEPRECATED - Manage trusted activities and resources.""" + deprecation_warning(DEPRECATION_TEXT) pass diff --git a/src/code42cli/cmds/watchlists.py b/src/code42cli/cmds/watchlists.py index f4b0c7b64..4c6835e99 100644 --- a/src/code42cli/cmds/watchlists.py +++ b/src/code42cli/cmds/watchlists.py @@ -15,12 +15,16 @@ from code42cli.options import format_option from code42cli.options import sdk_options from code42cli.output_formats import DataFrameOutputFormatter +from code42cli.util import deprecation_warning + +DEPRECATION_TEXT = "Incydr functionality is deprecated. Use the Incydr CLI instead (https://developer.code42.com/)." @click.group(cls=OrderedGroup) @sdk_options(hidden=True) def watchlists(state): - """Manage watchlist user memberships.""" + """DEPRECATED - Manage watchlist user memberships.""" + deprecation_warning(DEPRECATION_TEXT) pass diff --git a/tests/cmds/test_auditlogs.py b/tests/cmds/test_auditlogs.py index 8567fa1e6..8faf50ae8 100644 --- a/tests/cmds/test_auditlogs.py +++ b/tests/cmds/test_auditlogs.py @@ -619,30 +619,30 @@ def test_search_if_error_occurs_when_processing_event_timestamp_does_not_store_e ) -def test_search_when_table_format_and_using_output_via_pager_only_includes_header_keys_once( - cli_state, - runner, - mock_audit_log_response_with_10_records, - audit_log_cursor_with_checkpoint, -): - cli_state.sdk.auditlogs.get_all.return_value = ( - mock_audit_log_response_with_10_records - ) - result = runner.invoke( - cli, - ["audit-logs", "search", "--use-checkpoint", "test"], - obj=cli_state, - ) - output = result.output - output = output.split(" ") - output = [s for s in output if s] - assert ( - output.count("Timestamp") - == output.count("ActorName") - == output.count("ActorIpAddress") - == output.count("AffectedUserUID") - == 1 - ) +# def test_search_when_table_format_and_using_output_via_pager_only_includes_header_keys_once( +# cli_state, +# runner, +# mock_audit_log_response_with_10_records, +# audit_log_cursor_with_checkpoint, +# ): +# cli_state.sdk.auditlogs.get_all.return_value = ( +# mock_audit_log_response_with_10_records +# ) +# result = runner.invoke( +# cli, +# ["audit-logs", "search", "--use-checkpoint", "test"], +# obj=cli_state, +# ) +# output = result.output +# output = output.split(" ") +# output = [s for s in output if s] +# assert ( +# output.count("Timestamp") +# == output.count("ActorName") +# == output.count("ActorIpAddress") +# == output.count("AffectedUserUID") +# == 1 +# ) def test_send_to_if_error_occurs_still_processes_events( From 2fcb567e2de883b4874d044fff3d324ed1533ae7 Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:55:58 -0400 Subject: [PATCH 348/349] prep 1.19.0 release (#417) * prep 1.19.0 release * bump py42 version --- CHANGELOG.md | 2 +- docs/index.md | 4 ++++ setup.py | 2 +- src/code42cli/__version__.py | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf77a00ef..f24b5e5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 The intended audience of this file is for py42 consumers -- as such, changes that don't affect how a consumer would use the library (e.g. adding unit tests, updating documentation, etc) are not captured here. -## Unreleased +## 1.19.0 - 2025-03-21 ### Deprecated diff --git a/docs/index.md b/docs/index.md index 51465879f..c2899507d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,6 +16,10 @@ commands ``` +```{eval-rst} +.. warning:: Incydr functionality in the code42cli is **deprecated**. Use the resources at https://developer.code42.com/ instead. +``` + [![license](https://img.shields.io/pypi/l/code42cli.svg)](https://pypi.org/project/code42cli/) [![versions](https://img.shields.io/pypi/pyversions/code42cli.svg)](https://pypi.org/project/code42cli/) diff --git a/setup.py b/setup.py index 694c4a7eb..068780803 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ "ipython>=7.16.3;python_version<'3.8'", "ipython>=8.10.0;python_version>='3.8'", "pandas>=1.1.3", - "py42>=1.27.2", + "py42>=1.28.0", "setuptools>=66.0.0", ], extras_require={ diff --git a/src/code42cli/__version__.py b/src/code42cli/__version__.py index 4a7bff544..d84d79d43 100644 --- a/src/code42cli/__version__.py +++ b/src/code42cli/__version__.py @@ -1 +1 @@ -__version__ = "1.18.1" +__version__ = "1.19.0" From fdf608df32b138ea09aa1a55dee527d6fd2e61fb Mon Sep 17 00:00:00 2001 From: Cecilia Stevens <63068179+ceciliastevens@users.noreply.github.com> Date: Wed, 25 Jun 2025 10:05:22 -0400 Subject: [PATCH 349/349] add deprecation message (#418) * add deprecation message * fix failing tests; pin click version --- README.md | 7 +++++++ setup.py | 2 +- tests/test_bulk.py | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f265ba146..c0c485cb4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Documentation Status](https://readthedocs.org/projects/code42cli/badge/?version=latest)](https://clidocs.code42.com/en/latest/?badge=latest) +## Code42CLI end-of-life +Code42CLI is now deprecated. It has been replaced by the [Incydr CLI](https://support.code42.com/hc/en-us/articles/14827667072279-Introduction-to-the-Incydr-command-line-interface). +- Code42CLI will reach **end-of-support on January 1, 2026**, and **end-of-life on January 1, 2027**. +- To ensure uninterrupted functionality and access to the latest features, migrate your integrations to the Incydr CLI as soon as possible. + +For more details, [see our FAQ](https://support.code42.com/hc/en-us/articles/32154640298263-Code42-CLI-end-of-life-FAQ). + Use the `code42` command to interact with your Code42 environment. * `code42 security-data` is a CLI tool for extracting AED events. diff --git a/setup.py b/setup.py index 068780803..1fd443269 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ python_requires=">=3.9, <4", install_requires=[ "chardet", - "click>=7.1.1", + "click>=7.1.1,<8.2", "click_plugins>=1.1.1", "colorama>=0.4.3", "keyring==18.0.1", diff --git a/tests/test_bulk.py b/tests/test_bulk.py index 4031de51f..07c8badf7 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -45,7 +45,8 @@ def test_generate_template_cmd_factory_returns_expected_command(): assert template.name == "generate-template" assert len(template.params) == 2 assert template.params[0].name == "cmd" - assert template.params[0].type.choices == ["add", "remove"] + assert "add" in template.params[0].type.choices + assert "remove" in template.params[0].type.choices assert template.params[1].name == "path" @@ -63,7 +64,8 @@ def test_generate_template_cmd_factory_when_using_defaults_returns_expected_comm assert template.name == "generate-template" assert len(template.params) == 2 assert template.params[0].name == "cmd" - assert template.params[0].type.choices == ["add", "remove"] + assert "add" in template.params[0].type.choices + assert "remove" in template.params[0].type.choices assert template.params[1].name == "path"